Delegation & Agent Chains
Most of the time a token represents “this user, calling this resource.” AI workflows are rarely that simple — an orchestrator hands off to a planner, which hands off to a tool-executor. Each hop is a new actor acting on behalf of the original user. Token exchange (RFC 8693) is the OAuth mechanism that lets that handoff happen without ever re-prompting the user.
How an exchange works
Section titled “How an exchange works”-
The current holder presents its access token as
subject_tokenat/oauth/tokenwithgrant_type=urn:ietf:params:oauth:grant-type:token-exchange. -
AuthPlane verifies the subject token, checks who’s allowed to act for the target resource, and mints a new token.
-
The new token’s
subis still the original user. Itsactclaim records who performed the exchange. Its scope is never wider than the subject’s.
Why not just pass the original user token down the chain? Two reasons:
- Audience binding. Each hop calls a different resource — maybe a different MCP server. The original token’s
auddoesn’t match. - Accountability. When something goes wrong, the audit log must answer “which agent issued the destructive tool call?” — not just “which user authorized this session three hops ago.” The
actclaim gives you that chain; every hop is recorded in the token itself, and denied exchanges emit atoken.exchange_deniedaudit event.
The act claim — nested
Section titled “The act claim — nested”Each exchange wraps the previous one:
{ "sub": "user-uuid-v7", "client_id": "executor-agent", "scope": "mcp:echo", "act": { "sub": "executor-agent", "actor_type": "agent", "act": { "sub": "planner-agent", "actor_type": "agent", "act": { "sub": "orchestrator", "actor_type": "agent" } } }}The outermost act is the most recent actor. Per RFC 8693 §4.1 ¶6, only the outermost actor is authoritative for authorization decisions. Inner hops are informational — useful for audit and display, but your MCP server MUST NOT make access-control decisions based on them.
actor_type is "agent" if the acting client was registered with is_agent: true, otherwise "service". AuthPlane stamps it on the new outermost hop only; inner hops pass through unchanged.
The agent_id claim — opt-in agent identity
Section titled “The agent_id claim — opt-in agent identity”Not every client is an agent. Registering a client with the agent flag is what makes agent_id appear in its tokens:
curl -X POST http://localhost:9001/admin/clients \ -H "Authorization: Bearer $AUTHPLANE_ADMIN_API_KEY" \ -d '{ "client_name": "research-agent", "agent": true, "agent_description": "Searches the web and summarizes content" }'Non-agent clients (regular services, web apps) carry no agent_id claim at all — the presence of the claim is itself the signal. MCP servers should treat a missing agent_id as “the caller is not an identified agent,” not as an error. The is_agent flag is set at registration and is not editable via PATCH /admin/clients/{id} — to change it, delete and re-register the client.
The agent_chain claim — flat
Section titled “The agent_chain claim — flat”The nested act is technically complete but inconvenient to walk. AuthPlane also emits a flat, ordered list:
{ "sub": "user-uuid-v7", "agent_id": "executor-agent", "agent_chain": ["orchestrator", "planner-agent", "executor-agent"]}It reads left to right: first entry is the originator, last is the current actor. Same information as the nested act, but trivial for an MCP server to consume:
// Only allow direct agents (no sub-delegation) for sensitive toolsif len(claims.AgentChain) > 1 && isSensitiveTool(toolName) { return errors.New("sub-delegated agents cannot call sensitive tools")}
// Rate limit by the root agentrateLimitKey := claims.AgentChain[0]The agent_chain list is capped at 8 entries (oldest entries are truncated first) and is additive only — previous entries can’t be modified.
Scope narrowing
Section titled “Scope narrowing”The exchanged token’s scope must be ≤ the subject token’s scope — a client cannot escalate privileges through exchange. Narrowing is also useful deliberately: a service holding a broad token can self-exchange for a narrow one before handing it to a riskier downstream component.
Chain depth limits
Section titled “Chain depth limits”Delegation depth is bounded by token_exchange.max_chain_depth (env: AUTHPLANE_TOKEN_EXCHANGE_MAX_CHAIN_DEPTH) — an integer from 1 to 10, default 5. Exceeding it rejects the exchange with a chain_too_deep error.
Who’s allowed to exchange
Section titled “Who’s allowed to exchange”When a client requests a token exchange against a resource, three policies are checked in order. Any one passing authorizes the exchange:
- Self-exchange —
allow_self_exchange: trueAND the requesting client’sclient_idmatches the subject token’sclient_id. Used for scope narrowing. may_actclaim — the subject token carriesmay_act: {"sub": "<requesting client>"}; the original issuer pre-authorized this specific actor.- Per-resource policy — the target resource’s
policy.exchange.allowed_client_idsincludes the acting client (an empty list allows any consented client). For Broker resources, the three-bound consent check then runs on top.
If none pass, the exchange is denied with access_denied.
Backward compatibility
Section titled “Backward compatibility”- Tokens issued before agent identity was enabled continue to work; they just don’t carry
agent_idoragent_chain. - Non-agent clients are unaffected — their tokens are identical to before.