sk-… key; the access-control middleware resolves the key’s user and evaluates that user’s jq policy against the request context. You provision keys, map routes to scopes, and attach jq conditions.
Prerequisites
The gate is on by default. Before it can admit requests in a real deployment, these backing pieces must be in place.1
Access control is enabled
The gate runs unless you set
ACCESS_CONTROL_ENABLE=false. Leave it unset (or true) in production — the quickstart disables it only for local, auth-free development.2
A Redis for the live auth context
The per-user live counters live in a plain Redis — any standard
redis-server. No RedisJSON module or redis-stack image is required. There is no baked-in default: point the connection with ACCESS_CONTROL_REDIS_URL, or let it resolve from the shared TAI_DEFAULT_REDIS_URL — with the gate on, one must be set, or every request that carries a key is denied. The Redis API-key provider keeps its key records in its own plain Redis, TAI_IDENTITY_REDIS_URL, which also falls back to TAI_DEFAULT_REDIS_URL.3
A Postgres policy store
Scopes, route mappings, and per-user policy bodies live in Postgres — the only policy store. It lives in the named database the core
skeleton component binds to — the default database unless TAI_DB_BINDING_SKELETON points it elsewhere. Configure that database, including its password via TAI_DATABASE_DEFAULT_PG_PASSWORD.4
An identity provider in the manifest
The skeleton ships no concrete identity provider, so a deployment with the gate on must name one as a lifecycle module in the manifest. The default is the redis-backed api-key provider:It registers itself as the
examples/access_control/identity_manifest.yaml
redis provider at import. ACCESS_CONTROL_AUTH_PROVIDERS (an ordered JSON list) names the chain the verifier tries; unset, the chain is every registered identity provider, sorted by name. Swap the module and the provider name to use a different authn backend.5
Probe routes are public by default
A registered, authenticated route the operator mapped to no scope is reached by every role-holder out of the box (it resolves to the universal scope
*); only a path the app does not serve is denied to ordinary identities (a condition-free * super-admin key is still admitted — a root is never gated by a missing route). The liveness and readiness probes carry no key, but they are served public by the app’s own route-level declaration: /health and /ready ship in ACCESS_CONTROL_ACKNOWLEDGED_PUBLIC_ROUTES, so an orchestrator reaches them with no key and no manual pin.Initialize the deployment
With the gate on and no principal yet, there is no authenticated door to mint a key —tai keys create itself needs a key. The one-shot tai setup door (POST /api/setup) closes that gap: it creates the owner principal — the first admin — and mints the owner’s first condition-free ["*"] key, returned once.
TAI_SETUP_TOKEN— an operator-set token. When set, it is the token the door expects.- the auto-generated token — absent an operator token, each deployment fixes one random token once at startup and logs it a single time (
setup token: …). Only someone who can read the server log can initialize the deployment.
--token - reads it as one line from stdin (a value on argv leaks via ps and shell history).
The door is one-shot and secure by default: it refuses with 409 Already initialized the moment any principal exists, a wrong or absent token is a generic 403 (with a per-IP backoff that escalates against a token flood), and with the gate off (ACCESS_CONTROL_ENABLE=false) it is disabled with a 501 — there is no deployment to initialize. For local, auth-free development, TAI_SETUP_OPEN=true opens the door without a token.
Once the owner key is in hand, every further key is created through the authenticated surface below.
Initializing a deployment whose logins are OIDC
When your humans sign in through an external OIDC provider, that provider answers only who a caller is — it provisions nothing. An issuer-authenticated subject with no principal resolves to the empty policy, so every protected route denies it, and an OIDC identity provider mints no API keys. A fresh deployment still needs its first principal — the owner — and the setup door is the only way to create one. So the setup token matters even when every human logs in through OIDC: it guards the one door that mints the owner and the owner’s first key. Configure a key-minting identity provider beside the OIDC one inACCESS_CONTROL_AUTH_PROVIDERS — the bundled redis API-key provider mints the owner’s first key. The auto-generated setup token is minted and logged once, at INFO, only when the door can serve. No token is logged under four conditions; under the first the door still works — the token is your own value — and under the other three the door itself is unavailable and answers 501:
TAI_SETUP_TOKENis set — that value is the token, and the door uses it directly.- no configured identity provider can mint API keys — an OIDC-only chain mints none; add a key-minting provider.
- the access-control Redis is unset — the door needs it for the setup token, the throttle, and the mint lock.
- access control is off — there is no deployment to initialize.
1
Read the setup token
Take it from the server log, or pin it yourself with
TAI_SETUP_TOKEN.2
Create the owner and first key
Run
tai setup — it mints the owner principal and the owner’s first ["*"] key, returned once.3
Sign in with the key
The owner authenticates with that key while no interactive login exists yet.
4
Provision the OIDC subjects
The owner creates a principal for each OIDC subject through the admin principals door (
POST /api/auth/principals) and grants it a role.5
OIDC logins work
Each provisioned subject now signs in through OIDC; the setup door has closed for good.
TAI_SETUP_TOKEN from a Secret so the value is known before the pods start, rather than scraped from a replica’s log.
Provision an API key
Create a key for a user. The rawsk-… value is printed once — capture it then. Grant scopes with the repeatable --scope, and attach an inline jq authorization condition with --condition. A key needs at least one scope or a condition: a key with neither grants nothing, so the mint refuses it with a 400 and writes nothing — an api key needs at least one scope or a condition: a key with neither grants nothing, and every door treats it as no key.
--scope flag — the command’s own help confirms it:
examples/cli/keys_create_help.sh
--api-key-stdin flag, or set TAI_API_KEY in the environment. There is no --api-key VALUE flag, because a value would leak via ps and shell history.
Keys for automation
A machine identity is a service principal — a non-human principal that holds keys but never logs in. An admin creates one, then mints keys owned by it with--for:
--for names the principal a key belongs to; omitted, a key is owned by the caller’s own principal. Manage service principals with tai principals list|disable|enable|delete.
A principal and a key never share a user id. Creating a principal under the user id of a minted key is refused with a 409 and nothing is kept — user id 'ci' is an api key's id; a role is assigned to a principal, never to a key.
Map routes to scopes
This step is for scoped keys — a key minted with named scopes rather than*. Role-holders (admin/editor/viewer and any custom role, all carrying *) already reach every registered route, so a fresh deployment needs no route-mapping step to be usable. Map a route to a named scope only to let a scoped key reach it. A scope maps URLs to a named policy target; add a URL to a scope, optionally with a dynamic match pattern, and list the current mappings.
tai scopes add on a URL such a route serves, on any method, is refused with a 400 and nothing is written — mapping /health reads '/health' is public by its own declaration and stays open to everyone whatever scope it is mapped to, so it cannot be mapped into scope 'read'. tai scopes routes lists every route with its mapping, and its declared_public column is true for each route public by its own declaration.
The route table stores every URL in its canonical form — each segment percent-decoded once, repeated slashes collapsed, . and .. resolved, no trailing slash — the same form every request is looked up by. tai scopes add read /api/tools/ stores /api/tools, and removing or unpinning either spelling addresses the same row. A URL with no canonical form (a NUL, control or backslash byte after decoding) is refused with a 400. An ACCESS_CONTROL_PATH_PATTERNS template must itself be canonical (/studio-authed, never studio-authed or /studio-authed/); a non-canonical template refuses the settings load, naming the form to supply. A route row that is not canonical — one written to the database directly, never through a door — stops the deployment from booting, and the refusal names every such row to correct or delete; see access-control routes are canonical.
Write and validate a jq condition
A jq condition compiles against the request context and returns whether the request is allowed. Validate one before you save it — compile it, and optionally sample-evaluate it against aJqAuthContext-shaped sample.
--condition.
An edit that would leave a policy with neither a scope nor a condition is refused with a 400 and nothing is written — an edit (PUT /api/auth/api-keys/{user_id}) that sets "scopes": [] while no condition is set, tai keys edit --clear-condition on a key with no scope, or tai keys scopes --remove taking away the last scope of a key with no condition. The refusal names the repair for the row it refuses:
- a key reads
the edit leaves the policy with neither a scope nor a condition: a policy with neither grants nothing, and every door treats it as no key; revoke the key instead; - a principal’s own policy reads
the edit leaves the principal's policy with neither a scope nor a condition: a policy with neither grants nothing, and every door treats it as no key; disable or delete the principal instead; - the last enabled admin principal’s own policy is refused with the last-admin
409instead, ahead of the400.
.request.path, the canonical request path: a mount prefix the app is served under is removed, segments are decoded once, slashes collapsed, dot segments resolved and a trailing slash dropped. Write path tests against that form — .request.path | startswith("/api/tools") — never against a decoded-but-unnormalized or mount-prefixed spelling such as "/mnt/api/tools" or "/api/tools/"; a condition written against such a spelling never matches and must be rewritten. The validator’s sample verdict is the enforcer’s own: validate-condition with a sample runs the same evaluation the gate runs.
1
Draft the condition
Write a jq expression over the request context and compile-check it with
tai keys validate-condition.2
Attach it to a key
Create the key with
--condition — a templated-text JSON object carrying either inline jq (content) or a stored condition (id), plus optional render kwargs.3
Track policy changes
Each user’s enforced policy is append-only and versioned. List the history with
tai keys policy-versions, and roll back with tai keys policy-rollback.Verify enforcement
Prove the gate before you trust it: call a guarded route with two keys. A key whose policy grants the route’s scope reaches the resource with200; a valid key that lacks the scope is denied with a fixed 403. Set TAI_BASE_URL to your server and the X-Api-Key header to each key.
An allowed key reaches the resource:
examples/access_control/verify_allowed.sh
403, with the full reason logged only server-side:
examples/access_control/verify_denied.sh
AuthAdapter → AccessControlAuthBackend → ResourceGuardMiddleware chain in docs CI, so a change that breaks allow/deny enforcement breaks this step.
Delegate with owned keys
A key minted by a non-admin is an owned key — capped at a subset of its owner’s scopes, isolated to its own data, and attenuated with the owner on every request. To mint a capped key for a teammate or device, inspect what it can do withtai auth whoami, and share it by one-time QR claim link, follow the
owned-keys guide.
See also
- Access control — the key, scope, and jq policy model.
- Owned keys — capped sub-identities, the capability projection, and data isolation.
- The versioning spine — the append-only policy history.
- CLI reference — the full
tai keysandtai scopessurface.

