Runtime API authentication
For: application developers.
Authenticate clients and apps acting for individual people.
Authentication
Apps use one of the tenant's API keys. A tenant's admins make them with the CLI, or in the console under Admin › Access › API keys:
oz0 apikey create support-desk --tenant acme --role operator
oz0 apikey create dashboard --tenant acme --role reader --expires 30d
oz0 apikey list --tenant acme
oz0 apikey revoke 3f9c2a1b7d0e
The key is shown once, as oz0_<id>_<secret>; keep it as a secret. Send it as Authorization: Bearer <key>.
- A key has a role in its one tenant. A reader follows jobs. An operator also starts and cancels them and decides approvals. An admin also changes the tenant's nodes, rollouts and kill switches, through the operators' MCP server.
- Operator and admin keys see what jobs read and wrote, unless they are made with
--no-content. - Keys work on the runtime API and on the operators' MCP server (
/admin/mcpon the management port), with their role. They do not work on the admin API itself, or on Temporal's ports. An admin key is the admin API's. - A key lasts 90 days unless it is made with
--expires 30d, another time, ornever.
Who calls is known before the request is read
A request without a credential gets 401 before anything of it is read or checked, on every route (a CORS preflight, the published card keys and the methods nobody may use excepted), and an event stream takes a one-time ticket in its place. A credential that may not act in the tenant the URL names is refused before the body is read too; a stream ticket or a feed that names its tenant only in its body is refused before the body for a credential that is not valid at all, and in that tenant once it is read.
- A key ID that is not known costs the server little. Every server knows the IDs of the keys that exist: the edges from the registry they keep, management from the database, read when it starts and again every 15 seconds, and its own new keys at once.
A bearer whose key ID the server does not know is looked up only from a small share of its address's (an IPv6 address counts as its /64; behind a trusted proxy, the client's) and of the server's, so one address cannot make every request a database query, while a key that exists works however many unknown ones arrive, after a restart too.
Only a key another server made in the last few seconds can wait that long, while the unknown keys keep coming. - A revoked key stops working within seconds on every edge. The jobs a key starts, and the audit log, name it as
apikey:<id>. - A key outlives the account that made it only where the tenant says so. Removing an account (
account remove,user remove, or a provisioner) revokes the tenant's keys it made. A tenant whose admins setoz0 tenant removed-account-keys acme findingkeeps them working instead, and lists them until an admin revokes each one:oz0 apikey list --tenant acme --created-by-removed, a warning inoz0 doctor, and a banner with a filter on the API keys tab of Access.
Keys scoped to agents and flows
A key may reach only some of its tenant: give it one --scope per resource.
oz0 apikey create support-desk --tenant acme --role operator --scope agent:triage --scope flow:refund
oz0 apikey create chat-widget --tenant acme --role operator --scope agent:helper --scope conversation:own
| Scope | What the key may do |
|---|---|
agent:<name>, flow:<name> | Start jobs of that agent or flow, and read, list, watch, cancel and decide the approvals of its jobs and their child jobs; read and list the conversations whose latest turn is that agent's |
conversation:own | Read and list the conversations the key started (it started their first turn), and their turns' jobs; continuing one also takes the agent's scope. A turn someone else took there with an agent outside the key's scopes shows its facts only |
The edge refuses a scoped key everything outside its scopes: another agent's job or approval, a catalog entry it may not start (the catalog and ListStartable, the chat picker's list, show it only what it may start), and every call that does not check scopes, such as queues, routines, connections, tool calls by hand, events and feeds (API key <id> is scoped to agent:triage, and this call does not take scoped keys).
The agents' MCP server and A2A agents take scoped keys: they show only the agents and flows the key's scopes name, and answer another agent's task or context as one that does not exist. A scoped key does not work on the operators' MCP server, nor on the codec endpoint, which decodes any job's payloads of the tenant. Without --scope a key reaches its whole tenant, as before. Scopes are for reader and operator keys. A job's Server-Sent Events and a stream ticket for one take a scoped key for a job its scopes reach; a feed's do not.
Admin keys
A tool that administers one tenant, such as a usage exporter or an automation, uses an admin key instead of an operator's certificate: a key of the admin API with exactly the permissions it names in its one tenant.
oz0 apikey create usage-export --tenant acme --admin --permission usage.export --expires 90d
oz0 apikey create audit-sync --tenant acme --admin --permission audit.read --permission evidence.export --expires 30d
OZ0_ADMIN_KEY=oz0_... oz0 usage export --tenant acme --from 2026-10-01 --to 2026-11-01 --out october.csv
- The tenant's owners make them, and each permission must be one they hold. An admin key holds only permissions on the list of those an admin key may hold: a permission added to the platform later is no admin key's until it is put on that list. No admin key holds
members.manage,signin.manage,governance.relax,keys.manageorchanges.review: managing people, keys and reviews stays with people.content.readis the key's content flag (--content), for an owner who has it. - A key never outlasts its owner's say. On every call it holds only what its creator still holds in the tenant: demote the owner who made it, or remove them from the tenant, and the key loses what they lost; disable or remove their account and it stops. A key is its creator's hands, too: a change it proposes waits for someone other than its creator (four eyes,
second_admin), and nobody decides their own budget request through a key, or their key's request themselves. - An expiry is required, at most 90 days. The key's creation, its use (when its last use is recorded, at most once a minute), its revocation and its expiry are in the audit log (
apikey.create,apikey.used,apikey.revoke,apikey.expired). - It works on the admin API and the operators' MCP server, on the management port, as
Authorization: Bearer <key>: on the calls that act in its tenant by a permission it holds. Everything else refuses it: the cluster's calls, lists across tenants, a person's own calls, the runtime API and Temporal's ports. A key with onlyusage.exportanswersExportUsagefor its tenant and nothing else. - The CLI's admin commands take one from
$OZ0_ADMIN_KEY(never from an argument, which a process list shows), with a CLI context that names the management address and the CA. - The console's Access › API keys tab makes one for an owner: choose Admin key, then its permissions.
Apps that act as their people
An app whose people sign in through one of the cluster's identity providers can act as each of them, instead of as one key: its backend exchanges the signed-in person's token for a short-lived token of this cluster, with OAuth 2.0 Token Exchange (RFC 8693). Approvals, questions, starters, budgets and the audit log then see the real person, with the app beside them.
oz0 app create helpdesk --tenant acme --provider okta --client-id 0oa1helpdesk # prints the app's key once
oz0 app list --tenant acme
oz0 app delete a1b2c3d4e5f6 --tenant acme
curl --cacert ca.crt https://edge.example.com:7443/v1/token \
-d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
-d subject_token="$PERSONS_ID_TOKEN" -d subject_token_type=urn:ietf:params:oauth:token-type:id_token \
-d actor_token="$APP_KEY" -d actor_token_type=urn:oz0:params:oauth:token-type:api-key
{"access_token": "oz0at_...", "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", "token_type": "Bearer",
"expires_in": 900, "scope": "approvals.decide.named budget.request feeds.read jobs.cancel.own jobs.read.own jobs.start questions.answer.own"}
- The app is registered by the tenant's owners (
keys.manageandmembers.manage: an app acts as each member who signs in to it, owners too) with one of the cluster's identity providers (a cluster admin's choice: never a URL of the app's own) and the client ID the app itself signs its people in with there.
The client ID is the proof
Register your own app's client ID: a person's token for that client ID shows that they signed in to your app. A client ID belongs to one app in the whole cluster (removing the app frees it), and the cluster's own client ID, the console's, is refused. This cluster cannot see who owns a client at the provider, so the first tenant to register a client ID holds it; a cluster admin who finds an app holding another tenant's client removes it.
The person's token must be for that client ID; an ID token, or an access token that is a JWT, signed by the provider, not expired and at most an hour old (subject_token_type ...:id_token or ...:jwt).
- The person must have signed in to this cluster once through that provider, in the console or with
oz0 login; the token exchange finds them by the provider's subject, and makes no account.
What the token may do
The token carries the person's permissions in the app's tenant, narrowed to the app's permissions: by default jobs.start, jobs.read.own, jobs.cancel.own, questions.answer.own, approvals.decide.named, feeds.read and budget.request; an admin may give jobs.read, jobs.cancel, approvals.decide, questions.answer, jobs.start.granted or tenant.read instead (--permission), and content with --content for people who have it. A request's scope narrows it further.
Through an app a person decides only the approvals their own role allows and the app's permissions reach. Only their membership in the app's tenant counts: a cluster admin or cluster reader is neither through an app, and a person who is no member of the tenant is refused. The chat drawer's calls (chats.use) are not an app's.
The token lasts 15 minutes at most
The token expires no later than the person's token, and not a moment past its own expiry (an edge whose clock runs ahead refuses it that much early), and there is no refresh token: the app exchanges again. It works on the runtime API on the edge (Authorization: Bearer oz0at_...), its MCP and A2A routes and Server-Sent Events, never on the admin API. Every call it makes is the person's, with via: app:<id> in the audit entries it writes; each exchange is audited as app.token.issued, and an exchange that cannot be recorded issues no token.
A job started with the token stays the app's: an Ask turn acts for the person as the token did when it hands over to an agent, and the agent's job it starts goes on through the app; an approval that waited long is checked again with the person as the app lets them act then; and what its refusals show is what the app may read. Through the app, never as the whole person. Removing the app, the person's account or their membership, or turning the provider off, stops its tokens at their next call.
Refusals
The endpoint returns OAuth errors: invalid_client (the app's key), invalid_grant (the person's token, or a person this cluster does not know or who is no member of the tenant), invalid_scope, invalid_target (an audience or resource other than the app's tenant) and unsupported_grant_type. When the edge cannot read the app or the person's grant at that moment (its database is unreachable), it answers 503 temporarily_unavailable, never a refusal: exchange again shortly. A call with an app token is answered the same way, unavailable, and is refused (permission_denied) only when the app, the person's account or their membership is gone.
Limits
Each edge accepts 20 exchanges a second from one client address (bursts of 40; behind a trusted proxy, the client's the proxy names, and a proxy's request that names none is held by the app's limit alone) and 10 a second for one app (bursts of 30), and answers more with 429 and Retry-After. One exchange per person every quarter of an hour is what an app needs. A token signed with a key the edge does not hold makes it fetch the provider's keys again at most once a minute.
- The SDKs exchange for you:
exchangeTokenin TypeScript,ExchangeTokenin Go andexchange_tokenin Python. The chat app example is a complete app on it, through the AG-UI route, which takes resume entries only from a person: an app token, never the app's key. - The console's Access › API keys tab registers apps, beside the tenant's CORS origins, which an app's browser front end needs to read Server-Sent Events.
What a key creates is labelled with it
The jobs a key starts, their child jobs, the conversations it starts and the budget requests opened for it record the key's ID and name, as created_via: in ListJobs and GetJob (Job.created_via), on each turn GetConversation returns (ConversationTurn.created_via), on the console's job list (via support-desk) and job page, in the job's run.start audit entry, and on budget requests (apikey:<id> (<name>)). Every audit entry of the job tree also names the key as its starter.
Operators can use their certificate instead. server init writes one to the operator's directory, ~/.config/orchestrator-zero/<cluster>/, as operator.crt and operator.key, with the cluster's CA as ca.crt. Nodes are refused.
The edge's certificate comes from the cluster's own CA, so a client trusts that CA rather than the system's: ca.crt from an operator's directory, or fetched and checked the way the one-line install does.