Skip to content

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.

  1. The current holder presents its access token as subject_token at /oauth/token with grant_type=urn:ietf:params:oauth:grant-type:token-exchange.

  2. AuthPlane verifies the subject token, checks who’s allowed to act for the target resource, and mints a new token.

  3. The new token’s sub is still the original user. Its act claim 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 aud doesn’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 act claim gives you that chain; every hop is recorded in the token itself, and denied exchanges emit a token.exchange_denied audit event.

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:

Terminal window
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 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 tools
if len(claims.AgentChain) > 1 && isSensitiveTool(toolName) {
return errors.New("sub-delegated agents cannot call sensitive tools")
}
// Rate limit by the root agent
rateLimitKey := 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.

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.

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.

When a client requests a token exchange against a resource, three policies are checked in order. Any one passing authorizes the exchange:

  1. Self-exchangeallow_self_exchange: true AND the requesting client’s client_id matches the subject token’s client_id. Used for scope narrowing.
  2. may_act claim — the subject token carries may_act: {"sub": "<requesting client>"}; the original issuer pre-authorized this specific actor.
  3. Per-resource policy — the target resource’s policy.exchange.allowed_client_ids includes 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.

  • Tokens issued before agent identity was enabled continue to work; they just don’t carry agent_id or agent_chain.
  • Non-agent clients are unaffected — their tokens are identical to before.