Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.

SurfaceSchemeWho uses it
Browser UI, /api/sessions/*, /api/roots, /api/workers/* (browser half)Session cookie via OIDC loginA UI integration
/api/agent/* on the hubPer-worker OIDC client-credentialsA 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 subjectAn 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 subjectA 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:

ScopeUnlocks
agent:claimGET /api/agent/turns/next, GET /api/agent/jobs/next — claim one pending turn or job.
agent:reportPOST /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:heartbeatPUT /api/agent/workers/self/heartbeat only — report worker presence. Cannot claim or drive anything.
execution:controlEvery /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:

ScopeUnlocks
chat:readEvery GET under /api/remote/v1/ except the public auth-info — machines, projects, sessions, messages, search, and a turn’s events.
chat:drivePOST /api/remote/v1/sessions/{pk}/messages and POST /api/remote/v1/chats — send to a chat, start a chat.
workers:reserveThe reservation routes and POST /api/remote/v1/sessions/{pk}/move (which also needs chat:drive). Only when workers are enabled.
workers:adminThe 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 iss is the hub is judged only by the hub’s own, revocation-aware verifier. If it does not verify the answer is 401, and the external issuer is never asked.
  • Any other token is checked against your external identity provider, as described above. AGENT_ACCEPT_EXTERNAL_ISSUER=false turns this off, leaving only hub-issued tokens.
  • An external token whose sub starts client-w- is 401 whatever 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/* — 401 for 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"}
    

    403 when the token is otherwise valid (a non-blank subject) but lacks the scope the route requires:

    {"error": "missing required scope"}
    
  • Browser routes — 401 for no valid session:

    {"error": "authentication required"}
    

    (a browser navigation instead gets redirected to /auth/login rather than shown this JSON body).

  • /api/integrations/* (both the review queue and the work-run queue) — a NARROWER 401 than /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"}
    

    403 for either of two distinct reasons, both requiring a token that verified successfully: missing the scope that route requires (review:run or work: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 (sub doesn’t start with client-) answers a different body instead of the 401 /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, the sub doesn’t start with client-, or the sub isn’t on REMOTE_ALLOWED_SUBJECTS — one body for all three, so a caller can’t probe the allowlist. When the feature isn’t configured, every path answers 404 {"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.