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_POLICYnever starts, in any environment. - A misconfigured policy (a
jwtpolicy without a key, issuer or audience, say) stops the process outsideAPP_ENV=dev. Under dev the process starts, logs the problem and answers every request with 503 until it is fixed. APP_ENVcounts as dev only when it is exactlydev:DEV,development, ordevwith 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_KEYanswers 503, never "no auth". - For each environment,
secrets applyanddeploygenerate 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-tokenstands in for the identity provider. - For each deployed environment, set
AUTH_JWT_JWKS_URL(orAUTH_JWT_PUBLIC_KEY),AUTH_JWT_ISSUERandAUTH_JWT_AUDIENCEunderenv:invalues-<env>.yaml. Outside dev,deployrefuses (exit 3) while one is missing: the pods would refuse to start. - Every setting is in the
jwtsettings table below.
Your own code authenticates each request.
createscaffoldsapp/policies/custom.py, a documented stub that answers 503 to every request, and recordsauth_policy_implemented: falsein the manifest.deploy --env staging|prodrefuses until you implement the policy and setauth_policy_implemented: true.- Clients send what your policy reads:
run --header 'Name: value'or--cookie name=value. - How to implement it: Write a
custompolicy.
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_ |
The issuer's JWK set: fetched directly (no redirects), https outside dev unless the host is loopback. Set this or AUTH_, not both |
AUTH_ |
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_ |
The expected aud (a comma list is accepted); required outside APP_ENV=dev |
AUTH_ |
Allow-list: RS, PS and ES 256/384/512 and EdDSA; never none; the key type must match. Default RS256,ES256 |
AUTH_ |
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_ |
Claim holding the principal id (dotted path allowed; at most 256 characters). Default sub |
AUTH_ |
Claim holding the roles: a list, or a space- or comma-separated string (dotted path allowed, for example realm_). Default roles |
AUTH_ |
Clock skew allowed for exp, nbf and iat (0-600). Default 60 |
AUTH_ |
How long fetched keys are cached, in seconds (1-86400). Default 300 |
AUTH_ |
Allow a plain-http JWKS URL outside dev (a trusted in-cluster issuer only). Default false |
AUTH_ |
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_ |
The client (authorized party) claim; client_id is read when it is absent (RFC 9068). Okta: cid. Default azp |
AUTH_ |
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_ |
A token an agent presents, from an agent AUTH_ does not list |
403 Delegated caller <agent> is not allowed here (AUTH_ |
| 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:
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:
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-Authenticateheader 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;
authorizedecides actions (theACTIONSofapp_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 setactor, or this agent treats the calling agent as the person (see Agents calling agents). Ids are checked afterauthenticate: 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_ |
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:
jwtreads the RFC 8693 actor claim (act,AUTH_JWT_ACTOR_CLAIM): the outermostact.subis the current agent, and nestedactvalues are the agents before it. A malformedact(not a mapping with a stringsub, at any level) is refused with 401; it is never read as the user's own token. WithAUTH_JWT_DIRECT_CLIENTSset, a token with noactfrom 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.customsetsactoritself.app_utils.authexportsactor_from_claims(thejwtreading, for a policy that verifies tokens itself) andkeep_subject_token.shared-bearerhas one principal,shared, and no actor: any holder ofAPI_KEY, another agent included, can decide requester gates. Usejwtorcustomwhen agents call this one.
Then one rule set applies to every policy, right after authenticate:
| Variable | Default | Meaning |
|---|---|---|
AUTH_ |
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_ |
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_ |
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 itstaskId) 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 itscontextId. - No privileged roles. A delegated principal's roles never read across
(
AUTH_READ_ACROSS_ROLES), administer (AUTH_ADMIN_ROLES) or decide as arole:approver, whateverAUTH_DELEGATED_ROLESlends; 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):
- Each agent's client may exchange only for the audiences its peers need.
- Exchanged tokens name the calling client: in
act(with the earlier agents nested), or at least inazp/client_id, with the sign-in clients listed in the callee'sAUTH_JWT_DIRECT_CLIENTSandexchange.allow_actorless: trueon the caller's API (the caller refuses a token with no actor claim otherwise). - No exchange of a service's own token, nor of a token issued to another client.
- Exchanged tokens live 300 s or less, with a narrowed
scopewhere 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.
- Clients. In the realm (
agentshere),webis 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). - Audiences. A person's token for the concierge must carry
conciergeinaud(an audience mapper on a client scope ofweb), and the concierge must be allowed to ask for audienceorders(an audience mapper fororderson a client scope ofconcierge). Keep the access-token lifespan at 5 minutes or less. -
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 -
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"}): setAUTH_ALLOWED_ACTORS=concierge. - It carries only
azp: concierge(Keycloak's standard token exchange did not addactwhen 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, setAUTH_JWT_DIRECT_CLIENTS=web(the clients people sign in with) andAUTH_ALLOWED_ACTORS=client:concierge; then, on the concierge, opt the API in withexchange.allow_actorless: trueinapi-policy.yaml(or--allow-actorlessonapi add). The concierge logs a warning the first time Keycloak mints it a token with noact.
- The exchanged token carries
-
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/tokenDecode the
access_token:audnamesorders,subis the person,azp(andact, if present) namesconcierge, andexpires_inis 300 or less. Aninvalid_target, or anaudwithoutorders, 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_; locally, the API_KEY in .env when the variable is unset |
jwt |
GRAPH_; 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:
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
jwtaccepts 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 acustompolicy or a gateway for more (KI-042).- Hand-written ownership checks in tools must compare principal ids exactly, as
require_ownerdoes (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¶
-
With per-user principals, a second person holding a role can approve risky calls.
-
The security model and the checklist before production traffic.
-
Every setting the service reads, with its default.
-
The command's flags and exit codes.