Authentication
AYA has four separate credential schemes, each gating a different surface (and, when workers are enabled, a second token issuer; see Workload tokens). They are never interchangeable — a browser session cookie doesn’t work on an agent route, and an agent bearer token doesn’t work on a browser route.
| Surface | Scheme | Who uses it |
|---|---|---|
Browser UI, /api/sessions/*, /api/roots, /api/workers/* (browser half) | Session cookie via OIDC login | A UI integration |
/api/agent/* on the hub | Per-worker OIDC client-credentials | A worker (a dispatcher serves this same shape of surface itself, at its own address — see Integrate as a dispatcher) |
/api/integrations/* | OIDC client-credentials, service-principal subject | An external system queuing one of the hub’s own fixed reviews (review:run) or work runs (work:run), with no worker of its own |
/api/remote/v1/* | OIDC client-credentials, its own audience, service-principal subject | A remote agent that cannot join the operator’s network, reading sessions and driving chats on the operator’s machines through the hub (chat:read, chat:drive) — see Remote API |
Browser sessions
Every browser-facing route (the app shell, /api/sessions/*, /api/roots,
the browser half of /api/workers/*) sits behind a signed session cookie
set after an OIDC login redirect (/auth/login → your identity provider →
/auth/callback). There is no bearer-token mode for this browser surface
(the machine-to-machine Remote API is a separate surface with
its own credentials, not a bearer mode for this one) — if
you’re building a UI that a person logs into, this is what you integrate
against, and same-origin cookie mode is the only supported way in (see
Integrate as a UI). A request with no valid session gets
redirected to /auth/login (a browser navigation) or a 401 JSON body
(an API call), depending on the route.
Agent credentials (/api/agent/*)
Every route under /api/agent/* requires a per-worker OIDC client-credentials
bearer token: an OAuth2 access token obtained from your identity provider’s
token endpoint, scoped to exactly what that worker needs. The token’s sub is
the worker’s identity. The shared static AGENT_TOKEN that older hubs accepted
is gone; presenting one is a plain 401.
A worker only sees and serves the roots bound to its own sub: the first
worker to touch a root owns it (or the operator pins it), and index, capability
and claim calls for a root bound to someone else are refused with 403. Each
principal is also rate limited per route class; an exhausted bucket answers
429 with a Retry-After header.
To get an OIDC token, POST to your issuer’s token endpoint (discovered
from <issuer>/.well-known/openid-configuration) as
application/x-www-form-urlencoded:
curl -s -X POST "$OIDC_TOKEN_ENDPOINT" \
-d grant_type=client_credentials \
-d client_id="$CLIENT_ID" \
-d client_secret="$CLIENT_SECRET" \
-d resource="$RESOURCE" \
-d scope="agent:claim agent:report"
# {"access_token": "...", "expires_in": 3600}
resource is the RFC 8707 resource indicator — a deployment-specific value
your operator gives you, checked against the token’s aud claim on every
request. There is no separate “audience” parameter name on the wire; resource
is it. A token whose aud doesn’t match, or whose issuer doesn’t match the
hub’s configured issuer, is rejected the same as a missing token — 401.
Scopes. Space-separate multiple scopes in one request; a token can carry more than it needs, but each route checks for its own minimum. This table is the authoritative route→scope map:
| Scope | Unlocks |
|---|---|
agent:claim | GET /api/agent/turns/next, GET /api/agent/jobs/next — claim one pending turn or job. |
agent:report | POST /api/agent/turns/{id}/events, POST /api/agent/turns/{id}/done, GET /api/agent/turns, GET /api/agent/turns/{id}/history, POST /api/agent/jobs/{id}/result, GET /api/agent/index/fingerprints, POST /api/agent/index/sessions, POST /api/agent/capabilities — everything that reports progress, completion, or state on already-claimed work, plus read-only turn introspection. |
execution:heartbeat | PUT /api/agent/workers/self/heartbeat only — report worker presence. Cannot claim or drive anything. |
execution:control | Every /api/agent/workers/self/commands/* route (claim, events, heartbeat, code, complete) — drive a worker’s interactive provider login/logout. Cannot report presence or touch the turn/job queues. |
A route not in this table is unreachable — a perfectly valid token still
gets 401, because the middleware has no scope to check it against.
A third, unrelated scheme gates /api/integrations/* — for an external
product that wants the hub to run something for it without operating a
worker at all. It shares one OAuth2 client-credentials setup across two
independent scopes: review:run gates
POST /api/integrations/turns/GET /api/integrations/turns/{request_id}
(one automated read-only review); work:run gates
POST /api/integrations/work-runs/
GET /api/integrations/work-runs/{request_id} (a work run against an
operator-configured profile — see
Integrate as a dispatcher).
Both additionally require the token’s sub claim to identify a service
principal (it must start with client-); a human user’s token, even with
the right scope, is rejected. On this hub, both scopes are issued against
the SAME resource/audience value — there’s no separate work-run audience
to configure.
A fourth scheme gates /api/remote/v1/* — for a remote agent (for example a
cloud session that cannot join the operator’s VPN) that reads sessions and
drives chats on the operator’s machines through the hub. It uses its own
audience, the hub’s REMOTE_API_RESOURCE, which is never the same value as
API_RESOURCE or AGENT_OIDC_RESOURCE: a token minted for the review API or
the agent API does not open it, and the reverse. It has two scopes, and
chat:drive does not imply chat:read:
| Scope | Unlocks |
|---|---|
chat:read | Every GET under /api/remote/v1/ except the public auth-info — machines, projects, sessions, messages, search, and a turn’s events. |
chat:drive | POST /api/remote/v1/sessions/{pk}/messages and POST /api/remote/v1/chats — send to a chat, start a chat. |
workers:reserve | The reservation routes and POST /api/remote/v1/sessions/{pk}/move (which also needs chat:drive). Only when workers are enabled. |
workers:admin | The pool, client, machine and issuer-key admin routes. Only when workers are enabled. |
workers:reserve and workers:admin are separate from the two chat scopes:
holding one never grants another. GET /api/remote/v1/auth-info still lists
only chat:read and chat:drive. See Workers.
The token’s sub must also start with client- and be on the operator’s
REMOTE_ALLOWED_SUBJECTS list. Each admitted subject is rate limited, and an
exhausted bucket answers 429 with Retry-After. Browser cookies never open
this surface. See Remote API for the flow and the rules the hub
enforces on what such a caller may do.
Workload tokens and the second issuer
When the operator enables workers (WORKLOAD_ISSUER, see
Run your own) the hub is also an OIDC issuer
itself, at PUBLIC_BASE_URL, with discovery and a JWKS under /.well-known/
and a token endpoint at /oauth/token. It signs short-lived RS256 access tokens
for reserved workers, for service clients and for provisioning services. A
worker obtains its token by proving possession of the Ed25519 key it enrolled
with (private_key_jwt), so no shared secret sits on the machine; a service
client uses a secret. Worker tokens last 10 minutes by default and carry
sub client-w-<worker id> plus wid, pool and rsv claims naming the
worker, pool and reservation.
/api/agent/* therefore accepts tokens from two issuers, with one rule:
- A token whose
issis the hub is judged only by the hub’s own, revocation-aware verifier. If it does not verify the answer is401, and the external issuer is never asked. - Any other token is checked against your external identity provider, as
described above.
AGENT_ACCEPT_EXTERNAL_ISSUER=falseturns this off, leaving only hub-issued tokens. - An external token whose
substartsclient-w-is401whatever it carries: that prefix belongs to the workers the hub enrolls.
Releasing a reservation revokes its worker’s tokens in the same transaction, so
the worker’s next call to any /api/agent/* route is 401. Two new agent
routes serve reserved workers: GET /api/agent/wake (scope agent:claim) and
GET /api/agent/reservation (scope agent:report). The full details are in
docs/api/workers.md and docs/api/execution-pull.md.
Token lifetime and refresh
Access tokens are ordinary OAuth2 client-credentials tokens — their lifetime
is whatever your issuer puts in the token response’s expires_in field, not
something the hub controls. The hub itself does no token refresh on your
behalf; refreshing before expiry is your integration’s job. The reference
worker client refreshes 30 seconds ahead of the token’s stated expiry — or,
if expires_in is absent, assumes a 60-second lifetime and so refreshes
after 30 seconds — caches the token in memory, and
retries exactly once on an unexpected 401 by fetching a fresh token and
replaying the same request — a reasonable pattern to copy. The hub also
maintains its own signing-key cache (JWKS) independently, refreshing it when
it sees a key id it doesn’t recognize; that’s the hub’s own key-rotation
handling and is invisible to a well-behaved client.
Error responses
The exact error body differs by which of the surfaces above you’re on — they’re three separate implementations, not one shared auth layer:
-
/api/agent/*—401for no bearer token, a malformed one, one that fails signature/issuer/audience/expiry checks, one with a BLANK OIDC subject, or a route this guide doesn’t list under any scope:{"error": "unauthorized"}403when the token is otherwise valid (a non-blank subject) but lacks the scope the route requires:{"error": "missing required scope"} -
Browser routes —
401for no valid session:{"error": "authentication required"}(a browser navigation instead gets redirected to
/auth/loginrather than shown this JSON body). -
/api/integrations/*(both the review queue and the work-run queue) — a NARROWER401than/api/agent/*above: only for a missing bearer token, a malformed one, or one that fails signature/ issuer/audience/expiry verification — never for the subject itself, however it turns out:{"error": "authentication required"}403for either of two distinct reasons, both requiring a token that verified successfully: missing the scope that route requires (review:runorwork:run) answers{"error": "missing required scope"}, the same shape/api/agent/*uses (though/api/integrations/*never reaches this check on an unverified token, unlike/api/agent/*’s single combined check above); a verified token whose subject is BLANK or isn’t a service principal (subdoesn’t start withclient-) answers a different body instead of the401/api/agent/*would give a blank subject:{"error": "service principal required"} -
/api/remote/v1/*—401{"error": "authentication required"}for a missing, malformed or unverifiable bearer (wrong signature, issuer, audience or expiry — including a token minted for a different surface).403{"error": "missing required scope"}for every other refusal of a verified token: the scope the route needs is absent, thesubdoesn’t start withclient-, or thesubisn’t onREMOTE_ALLOWED_SUBJECTS— one body for all three, so a caller can’t probe the allowlist. When the feature isn’t configured, every path answers404{"error": "remote API is unavailable"}before any auth check runs.
A third response is specific to /api/agent/*: if a hub has no agent
authentication configured at all, every route under /api/agent/* answers
503 unconditionally — no auth check is even attempted, so a misconfigured
deployment fails obviously rather than looking like a routing bug:
{"error": "agent routes are disabled — agent authentication is not configured"}
/api/integrations/* is configured independently and does not share
that 503 — see Reference
for its own two distinct unavailable states, checked separately for the
review queue and the work-run queue: a plain 404 (that particular queue
isn’t configured at all — before any auth check runs) versus a 503
only once authentication has already succeeded but nothing on the hub
could execute the resulting turn or run.