Skip to content

Authentication

One auth policy guards every surface of the generated service. Choose shared-bearer, per-user jwt or a custom policy of your own, configure it, and run it locally with the same credentials a client would send.

What the policy guards

AUTH_POLICY selects the policy; create --auth-policy sets it and the manifest records it as auth_policy.

Surface Guarded
POST /chat, the thread routes, the approval routes yes
The A2A agent card and JSON-RPC endpoint yes
Under langgraph-server: the server's native API (assistants, threads, runs, crons, store) yes, as the server's auth handler
GET /health, GET /ready no (probes)
GET /metrics no, unless METRICS_TOKEN is set (Observability)
/playground, /docs, /openapi.json no; they exist only under APP_ENV=dev

Threads and A2A tasks belong to the principal that created them, whatever the policy; a person also reaches the work their agents did for them (Agents calling agents).

Choose a policy

Policy Principals Roles Use it for
shared-bearer (default) one: every caller is shared none internal tools, service-to-service calls, development
jwt one per user, from a verified OIDC/JWT token from a token claim users signed in through an identity provider (Keycloak, Auth0, Entra ID, Okta, Dex, ...)
custom whatever your code returns whatever your code returns anything else: an existing application's session cookie, an API gateway's identity headers

Under shared-bearer thread ownership separates nobody and only requester approval gates can be decided; per-user ownership, read-across roles and four-eyes approvals need jwt or custom.

Startup fails closed

  • An unknown AUTH_POLICY never starts, in any environment.
  • A misconfigured policy (a jwt policy without a key, issuer or audience, say) stops the process outside APP_ENV=dev. Under dev the process starts, logs the problem and answers every request with 503 until it is fixed.
  • APP_ENV counts as dev only when it is exactly dev: DEV, development, or dev with a space around it is a deployed environment. The app and the chart compare it as is.

Set up your policy

Clients send Authorization: Bearer <API_KEY>; the server compares it in constant time.

graph-agents-cli create my-agent      # shared-bearer is the default
cd my-agent && cp .env.example .env
graph-agents-cli login --write-env    # generates API_KEY in .env
graph-agents-cli run "hello"          # sends the API_KEY from .env
  • An unset API_KEY answers 503, never "no auth".
  • For each environment, secrets apply and deploy generate a key when neither the env file nor the live Secret has one, and never replace a live key without --rotate-api-key (Secrets).
  • To call a deployed agent, put its key in GRAPH_AGENTS_CLI_API_KEY.

Clients send Authorization: Bearer <token>: a token your identity provider signed.

graph-agents-cli create my-agent --auth-policy jwt
cd my-agent && cp .env.example .env
graph-agents-cli install
export GRAPH_AGENTS_CLI_API_KEY="$(graph-agents-cli auth dev-token --sub alice --roles user)"
graph-agents-cli run "hello"
  • Locally, auth dev-token stands in for the identity provider.
  • For each deployed environment, set AUTH_JWT_JWKS_URL (or AUTH_JWT_PUBLIC_KEY), AUTH_JWT_ISSUER and AUTH_JWT_AUDIENCE under env: in values-<env>.yaml. Outside dev, deploy refuses (exit 3) while one is missing: the pods would refuse to start.
  • Every setting is in the jwt settings table below.

Your own code authenticates each request.

graph-agents-cli create my-agent --auth-policy custom
  • create scaffolds app/policies/custom.py, a documented stub that answers 503 to every request, and records auth_policy_implemented: false in the manifest.
  • deploy --env staging|prod refuses until you implement the policy and set auth_policy_implemented: true.
  • Clients send what your policy reads: run --header 'Name: value' or --cookie name=value.
  • How to implement it: Write a custom policy.

jwt settings

Per-user principals from a verified OIDC/JWT bearer token. The settings live in .env locally and in the chart values per environment; only AUTH_JWT_SECRET is a secret.

Variable Meaning
AUTH_JWT_JWKS_URL The issuer's JWK set: fetched directly (no redirects), https outside dev unless the host is loopback. Set this or AUTH_JWT_PUBLIC_KEY, not both
AUTH_JWT_PUBLIC_KEY One PEM public key or certificate (only its key is used; \n escapes accepted)
AUTH_JWT_ISSUER The expected iss; required outside APP_ENV=dev
AUTH_JWT_AUDIENCE The expected aud (a comma list is accepted); required outside APP_ENV=dev
AUTH_JWT_ALGORITHMS Allow-list: RS, PS and ES 256/384/512 and EdDSA; never none; the key type must match. Default RS256,ES256
AUTH_JWT_ALLOW_HS true also allows HS256/384/512, verified with AUTH_JWT_SECRET. Default false
AUTH_JWT_SECRET Shared secret for the HS algorithms, at least 32 bytes. A secret: the CLI adds it to the Secret's allow-list when the chart values or env file opt into them
AUTH_JWT_PRINCIPAL_CLAIM Claim holding the principal id (dotted path allowed; at most 256 characters). Default sub
AUTH_JWT_ROLES_CLAIM Claim holding the roles: a list, or a space- or comma-separated string (dotted path allowed, for example realm_access.roles). Default roles
AUTH_JWT_LEEWAY_S Clock skew allowed for exp, nbf and iat (0-600). Default 60
AUTH_JWT_JWKS_CACHE_S How long fetched keys are cached, in seconds (1-86400). Default 300
AUTH_JWT_JWKS_ALLOW_HTTP Allow a plain-http JWKS URL outside dev (a trusted in-cluster issuer only). Default false
AUTH_JWT_ACTOR_CLAIM The RFC 8693 actor claim (dotted path allowed): a token carrying it is the user's, presented by that agent. Set it empty to read every token as the user's own (0.2). Default act
AUTH_JWT_CLIENT_CLAIM The client (authorized party) claim; client_id is read when it is absent (RFC 9068). Okta: cid. Default azp
AUTH_JWT_DIRECT_CLIENTS Comma list of the clients people sign in with. When set, a token with no actor claim from any other client is that client presenting the user's token (actor client:<client>). Default empty

Responses

Request Answer
No token 401 Missing bearer token.
An invalid token: expired, not yet valid, wrong audience or issuer, bad signature, algorithm not allowed, unknown key, malformed, over 16384 characters, no principal claim, a malformed actor claim (invalid actor claim), too many agents in it (delegation too deep) 401 Invalid bearer token: <reason>. with an RFC 6750 challenge: WWW-Authenticate: Bearer error="invalid_token", error_description="<reason>"
A token an agent presents, from an agent AUTH_ALLOWED_ACTORS does not list 403 Delegated caller <agent> is not allowed here (AUTH_ALLOWED_ACTORS).
A misconfigured policy, or no usable keys 503 (the details are in the server log)

Nothing from the token is logged.

Keys

  • One fetch at a time; an unknown key id triggers at most one refetch per 30 s.
  • An expired cache is refreshed in the background while the cached keys keep verifying.
  • When the issuer is unreachable, the last good keys stay usable for one more hour, then requests get 503 until it answers.

Local runs with dev tokens

graph-agents-cli auth dev-token lets a jwt project run without an identity provider:

export GRAPH_AGENTS_CLI_API_KEY="$(graph-agents-cli auth dev-token --sub alice --roles user)"
graph-agents-cli run "hello"
graph-agents-cli eval run

The first call creates an RSA key pair in .graph-agents-cli/dev-jwt/ (git ignored; the private key is mode 0600) and fills the blank AUTH_JWT_PUBLIC_KEY, AUTH_JWT_ISSUER and AUTH_JWT_AUDIENCE in .env. It prints the token alone on stdout, so the command above keeps it out of argv and your shell history. Tokens last 12 hours by default (--ttl, at most 7d). Restart a kept local server (graph-agents-cli run --stop-server) after the first call.

Mint one token per test user to try thread ownership, roles and approvals: a token for --sub bob --roles ops decides the calls a role:ops gate holds. --act concierge mints the token the agent concierge presents for the user (repeat --act for a chain, the current agent first; --azp sets the client): see Agents calling agents.

auth dev-token refuses (exit 3) unless the project's policy is jwt, APP_ENV is exactly dev, and .env names no JWKS URL and no other public key:

Output
Error: APP_ENV is 'staging'; dev tokens are only for a local server under APP_ENV=dev (set it in .env, as .env.example does). Deployed environments take tokens from your identity provider.

Never deploy the dev key

The dev key lives only in .env and .graph-agents-cli/dev-jwt/. Deployed environments verify tokens from your identity provider (AUTH_JWT_JWKS_URL in the chart values); deploy and secrets read values-<env>.yaml and .env.<env>, never your .env.

Write a custom policy

Implement CustomPolicy in app/policies/custom.py. Both methods are awaited on every request:

app/policies/custom.py
from fastapi import HTTPException, Request

from app.app_utils.auth import ACTIONS, Principal


class CustomPolicy:
    async def authenticate(self, request: Request) -> Principal:
        session = request.cookies.get("session")
        user = await my_session_store.lookup(session)  # your async lookup
        if user is None:
            raise HTTPException(401, "Not signed in.", headers={"WWW-Authenticate": "Cookie"})
        return Principal(
            id=user.id,  # stable, unique: owns threads and A2A tasks
            roles=user.roles,  # matched against AUTH_*_ROLES and role: approvers
            permissions=set(ACTIONS),
            attributes={"tenant": user.tenant},  # secrets only under "credentials"
        )

    async def authorize(self, principal: Principal, action: str, resource: str | None) -> None:
        if action not in principal.permissions:
            raise HTTPException(403, f"{action} is not allowed.")

    def startup_problems(self) -> list[str]:  # optional: stops startup outside dev
        return [] if MY_SETTING else ["MY_SETTING is not set"]

Rules for the implementation:

  • Raise 401 with a WWW-Authenticate header for a missing or invalid credential, and 503 when the issuer cannot be reached.
  • Never put the credential in an error detail or a log line.
  • Do I/O asynchronously and cache sessions or keys briefly.
  • Keep principal ids stable, unique and compared exactly: two callers with one id see each other's conversations, and ids that differ only in case are two principals. Role names must not contain commas.
  • Thread ownership is enforced outside the policy; authorize decides actions (the ACTIONS of app_utils.auth: chat.send, thread.read, thread.list, thread.delete, run.read, a2a.invoke, card.read, approval.read, approval.decide).
  • When the credential shows that an agent presents it for a user, set Principal(id=<user>, actor=Actor(id=<agent>)). A custom policy that lets another agent forward users' credentials must set actor, or this agent treats the calling agent as the person (see Agents calling agents). Ids are checked after authenticate: 1-256 characters without control characters, or the request fails with 500 and the policy bug is logged.

A credential that tools must forward to an auth: forward API goes in attributes["credentials"][<api name>]: the only attribute that may hold a secret. Principal.public_attributes() (every attribute but credentials) is what gets persisted, logged or traced.

Under langgraph-server with LANGGRAPH_SERVER_URL set, AUTH_FORWARD_HEADERS (default authorization,cookie) lists the request headers passed on to the server's auth handler: the headers your policy reads.

When it works, add tests beside tests/unit/test_policy.py (a valid credential, a missing one, an invalid one, two principals that must not see each other's threads), then set auth_policy_implemented: true in the manifest.

Roles and shared settings

Variable Default Meaning
AUTH_READ_ACROSS_ROLES empty Comma list of roles that may read other principals' threads (and list, never decide, their approvals); never continue or delete them
AUTH_ADMIN_ROLES empty (nobody) Under langgraph-server: roles that may create, update or delete assistants and crons and write the store. Reads are open to any authenticated principal; every other native-API action is denied

Under langgraph-server, the native API is also held to the approval rules: a native run cannot resume a paused run (decide through the approval routes), a run without input or from a checkpoint is refused on a thread that has approvals or waits on a gated call, and a thread that has approvals is not copied. A native run's tools act for the caller, as a /chat run's do: the auth handler replaces any run context the request sends (context, or config.configurable) with the caller's own id, roles and public attributes (@actor included, credentials never).

Agents calling agents

A request can come from another agent acting for a user: agent A received the user's request and calls this agent for them. The subject (Principal.id) is still the user; the actor (Principal.actor) is the agent presenting the request. A principal with an actor is delegated; one without is direct. This section is the identity side; the whole setup, from peer add to relayed approvals, is walked through in Agents calling agents.

How a policy knows:

  • jwt reads the RFC 8693 actor claim (act, AUTH_JWT_ACTOR_CLAIM): the outermost act.sub is the current agent, and nested act values are the agents before it. A malformed act (not a mapping with a string sub, at any level) is refused with 401; it is never read as the user's own token. With AUTH_JWT_DIRECT_CLIENTS set, a token with no act from a client not listed there is that client presenting the user's token (client:<azp>); a service's own token (its subject is its client) stays direct.
  • custom sets actor itself. app_utils.auth exports actor_from_claims (the jwt reading, for a policy that verifies tokens itself) and keep_subject_token.
  • shared-bearer has one principal, shared, and no actor: any holder of API_KEY, another agent included, can decide requester gates. Use jwt or custom when agents call this one.

Then one rule set applies to every policy, right after authenticate:

Variable Default Meaning
AUTH_ALLOWED_ACTORS empty (no agent) Comma list of the agents that may call this one for a user, or * for any (the issuer's audience policy alone then decides). Any other delegated request gets 403.
AUTH_DELEGATED_ROLES empty (none) The roles a delegated request keeps: an agent acting for a user holds none of the user's roles unless listed here.
AUTH_MAX_DELEGATION_DEPTH 3 How many agents may stand between the user and this one (1-8); a longer chain gets 401.

A bad value stops startup. With jwt, AUTH_ALLOWED_ACTORS set and AUTH_JWT_DIRECT_CLIENTS empty, startup logs that delegation is recognised only by the actor claim: if your issuer's exchanged tokens carry none, list your sign-in clients in AUTH_JWT_DIRECT_CLIENTS. An agent that calls others with auth: exchange refuses to send an exchanged token with no actor claim, unless the API sets exchange.allow_actorless: true (then it logs a warning the first time the issuer mints it one).

What a delegated principal reaches:

  • Its own work only. Threads, A2A tasks and approvals belong to the subject and the actor. An agent sees and continues only what it started for that user; another agent acting for the same user gets the usual answers for something that is not theirs (403 This thread belongs to another principal., A2A -32001 task not found).
  • The person owns everything done for them. The user, calling directly, reads, continues and deletes the threads their agents started, and decides their approvals. They also read (GetTask), list (ListTasks) and cancel (CancelTask) the A2A tasks their agents started for them, with their own (not delegated) token: the task is canceled where it is, and the agent sees it canceled. Continuing such a task (a message naming its taskId) and subscribing to it stay with the agent that started it (-32001 for the person); the conversation itself is the person's thread, which they continue by its contextId.
  • No privileged roles. A delegated principal's roles never read across (AUTH_READ_ACROSS_ROLES), administer (AUTH_ADMIN_ROLES) or decide as a role: approver, whatever AUTH_DELEGATED_ROLES lends; a lent role is visible to tools only.
  • It never decides an approval. The person decides a gated call with their own credentials; see Human approval.

The actor is published in the principal's public attributes (attributes["@actor"], the agent's id, chain and client), so it reaches tools under both runtimes and is recorded with an approval's requester. Logs carry it as actor (a client name, not personal data), and run records and trace metadata name it too.

Calling another agent for the user

The calling side is an auth: exchange API: when a tool calls it, the agent exchanges the user's verified token at the identity provider for one minted for the other agent (RFC 8693), and sends that. Under jwt the user's token is kept for this (in the principal's private credentials, never stored, logged or traced) only while api-policy.yaml has such an API, or an auth: forward one with forward_audience; a custom policy calls keep_subject_token(principal, token, aud, exp).

What the identity provider must guarantee (the agents cannot enforce it):

  1. Each agent's client may exchange only for the audiences its peers need.
  2. Exchanged tokens name the calling client: in act (with the earlier agents nested), or at least in azp/client_id, with the sign-in clients listed in the callee's AUTH_JWT_DIRECT_CLIENTS and exchange.allow_actorless: true on the caller's API (the caller refuses a token with no actor claim otherwise).
  3. No exchange of a service's own token, nor of a token issued to another client.
  4. Exchanged tokens live 300 s or less, with a narrowed scope where the provider supports it.

Token exchange with Keycloak

A recipe for two agents, concierge calling orders for a person who signs in through the web client. Keycloak's standard token exchange (RFC 8693) ships with Keycloak 26.2 and later; earlier releases had only a preview feature with a different setup. Screens, option names and defaults change between releases: check each step against your release's documentation.

  1. Clients. In the realm (agents here), web is the sign-in client. Each agent is a confidential client whose client id is its audience: concierge, orders. Turn on standard token exchange for each client that exchanges tokens (concierge).
  2. Audiences. A person's token for the concierge must carry concierge in aud (an audience mapper on a client scope of web), and the concierge must be allowed to ask for audience orders (an audience mapper for orders on a client scope of concierge). Keep the access-token lifespan at 5 minutes or less.
  3. The concierge (jwt, calling out):

    AUTH_POLICY=jwt
    AUTH_JWT_ISSUER=https://sso.example.com/realms/agents
    AUTH_JWT_JWKS_URL=https://sso.example.com/realms/agents/protocol/openid-connect/certs
    AUTH_JWT_AUDIENCE=concierge
    AUTH_JWT_ROLES_CLAIM=realm_access.roles
    TOKEN_EXCHANGE_URL=https://sso.example.com/realms/agents/protocol/openid-connect/token
    TOKEN_EXCHANGE_CLIENT_ID=concierge
    TOKEN_EXCHANGE_CLIENT_SECRET=...   # .env, and the Secret through secrets.keys
    
    graph-agents-cli api add orders_agent --base-url-env ORDERS_AGENT_URL --auth exchange \
      --audience orders --access custom --methods GET,POST
    
  4. Orders (jwt, called): AUTH_JWT_AUDIENCE=orders, the same issuer and JWKS URL. Then tell it how the provider marks a token the concierge presents:

    • The exchanged token carries act ({"sub": "concierge"}): set AUTH_ALLOWED_ACTORS=concierge.
    • It carries only azp: concierge (Keycloak's standard token exchange did not add act when this was written; decode one to see): the concierge refuses to send such a token (the tool reads "the issuer's token names no actor"), since orders would read it as the person's own and would let the concierge decide the person's approvals there. On orders, set AUTH_JWT_DIRECT_CLIENTS=web (the clients people sign in with) and AUTH_ALLOWED_ACTORS=client:concierge; then, on the concierge, opt the API in with exchange.allow_actorless: true in api-policy.yaml (or --allow-actorless on api add). The concierge logs a warning the first time Keycloak mints it a token with no act.
  5. Check the exchange by hand, with a person's token for the concierge in $USER_TOKEN:

    curl -s -u "concierge:$TOKEN_EXCHANGE_CLIENT_SECRET" \
      -d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
      -d subject_token="$USER_TOKEN" \
      -d subject_token_type=urn:ietf:params:oauth:token-type:access_token \
      -d requested_token_type=urn:ietf:params:oauth:token-type:access_token \
      -d audience=orders \
      https://sso.example.com/realms/agents/protocol/openid-connect/token
    

    Decode the access_token: aud names orders, sub is the person, azp (and act, if present) names concierge, and expires_in is 300 or less. An invalid_target, or an aud without orders, means step 2 is incomplete.

Limits with an issuer that names no actor in act (usable only with allow_actorless): the callee sees only the last agent, so AUTH_MAX_DELEGATION_DEPTH and the loop check (which reads client:<id> as the agent <id>) see one hop, and every agent that may call another needs its own client listed. Omit exchange.resource unless your provider supports RFC 8707 resource indicators.

Clients and credentials

run, eval and approvals send the same credentials, locally and with --url:

Policy How the CLI authenticates
shared-bearer GRAPH_AGENTS_CLI_API_KEY=<API_KEY>; locally, the API_KEY in .env when the variable is unset
jwt GRAPH_AGENTS_CLI_API_KEY=<token>; locally, a token from auth dev-token
custom --header 'Name: value' or --cookie name=value (repeatable)

The CLI sends GRAPH_AGENTS_CLI_API_KEY as Authorization: Bearer <value>. Prefer it to --header for any bearer credential: argv is visible to other local users and lands in shell history. An Authorization header given with -H overrides the variable.

graph-agents-cli login reports what is missing: the provider key, API_KEY under shared-bearer, the verification key and the token under jwt, and a .env other users can read. A run that fails authentication prints the fix:

Output
Error: Agent request failed (HTTP 401):
  {"detail":"Missing bearer token."}
  Authentication failed. jwt: export GRAPH_AGENTS_CLI_API_KEY=<token>; for a local token: export GRAPH_AGENTS_CLI_API_KEY="$(graph-agents-cli auth dev-token --sub <user>)".

Known limitations

Limits of the built-in policies

  • jwt accepts one issuer and maps no tenant or scope claims to permissions: every authenticated principal may use every action, and ownership is per thread (and per agent, for a delegated request). The JWKS URL must answer without redirects; for a PEM certificate only its public key is used. Use a custom policy or a gateway for more (KI-042).
  • Hand-written ownership checks in tools must compare principal ids exactly, as require_owner does (KI-043).
  • Under langgraph-server, the server's own access log records auth failures that clients see as 503 as 500 (KI-055).

Next steps