Grants & flows
TL;DR — AuthPlane implements five OAuth grants. Authorization code + PKCE for interactive users (the default), refresh token for silent renewal with reuse detection, client credentials for machine-to-machine, token exchange (RFC 8693) for delegation and vending upstream tokens, and JWT bearer (RFC 7523) for enterprise-asserted agent identity. Pick the grant by asking “who is authenticating, and against what?”.
The five grants at a glance
All five arrive at the same endpoint — POST /oauth/token — differentiated by the grant_type parameter.
Authorization code + PKCE
The default flow when a human is in the loop. PKCE-S256 is mandatory — AuthPlane rejects the plain challenge method entirely.
Wire sequence:
1. Agent → AS GET /oauth/authorize?response_type=code
&client_id=…
&redirect_uri=…
&code_challenge=<sha256(verifier), base64url>
&code_challenge_method=S256
&scope=tools/read
&resource=https://api.example.com/mcp
&state=<csrf>
2. AS → User 302 Location: /login (if no session)
3. User → AS POST /login (email + password, or OIDC federated)
4. User → AS POST /consent (approve)
5. AS → Agent 302 Location: <redirect_uri>?code=<authcode>&state=<csrf>
6. Agent → AS POST /oauth/token
grant_type=authorization_code
code=<authcode>
code_verifier=<pre-hash verifier>
client_id=…
redirect_uri=…
resource=https://api.example.com/mcp
7. AS → Agent 200 { access_token, refresh_token, expires_in, token_type }
Key details:
code_challenge+code_verifier— the client generates a random string (verifier), sends its SHA-256 hash on/authorize, and reveals the raw string on/token. AuthPlane verifies the hash matches. Prevents authorization code interception (the T1 threat).resourceparameter — RFC 8707 resource indicator. AuthPlane binds the token’saudclaim to this URI. Your MCP server should reject tokens whoseauddoesn’t include its own URI.stateparameter — client-generated CSRF nonce. The client verifies it comes back unchanged on the redirect.- Consent flow — AuthPlane records the user’s approval in
consent_grants. Re-authorization requests for the same client + scopes skip the consent screen unless the grant was revoked or scopes changed. - Token binding to DPoP — if the client includes a
DPoPheader on/oauth/token, the resulting token getstoken_type: "DPoP"and acnf.jktclaim binding it to the client’s key. See DPoP concept.
Deep dive: Guides: Connect an MCP client walks the full flow with a real client (Claude Desktop, Inspector).
Refresh token
Access tokens are short-lived by design (default 15 minutes) so a leak has a small blast radius. Refresh tokens let the client renew without another consent screen.
Refresh token rotation is mandatory — every refresh returns a new refresh token and invalidates the old one. If AuthPlane ever sees the same refresh token used twice, it treats the entire family as compromised: every token derived from it is revoked. This is refresh token reuse detection.
Agent → AS POST /oauth/token
grant_type=refresh_token
refresh_token=<opaque handle>
client_id=…
AS → Agent 200 { access_token: <fresh JWT>,
refresh_token: <NEW opaque handle>, ← rotated
expires_in: 900 }
Refresh tokens are opaque handles (not JWTs) — stored server-side, revocable per-family. See Security: Token design for the rationale.
Client credentials
Machine-to-machine. The client (a backend service, CI job, automation agent) proves its own identity with client_id + client_secret and gets a token with the scopes it was registered for. No user, no consent, no refresh token.
Backend → AS POST /oauth/token
grant_type=client_credentials
client_id=…
client_secret=…
scope=tools/echo tools/query_database
resource=https://api.example.com/mcp
AS → Backend 200 { access_token, expires_in, token_type }
Key details:
- Enable it explicitly — disabled by default. Turn on with
client_credentials.enabled: trueorAUTHPLANE_CLIENT_CREDENTIALS_ENABLED=true. - Scope resolution — if the request includes
scope, the token gets the intersection with the client’s registered scopes. If it doesn’t, the token gets all registered scopes. subclaim = the client_id (not a user). Your MCP server can distinguish machine tokens from user tokens by this.- No refresh token — clients re-authenticate instantly. Smaller blast radius on token leak, simpler rotation logic.
- DPoP-compatible — pass a
DPoPheader at/oauth/tokento get a bound token.
Deep dive: Guides: Wire up client credentials and the full grant reference in the authserver repo.
Token exchange (RFC 8693)
Two very different use cases share this grant:
On-behalf-of delegation
An orchestrator agent (agent A) receives a user’s token and wants to hand a narrower one to a sub-agent (agent B). Or an MCP server wants a token naming itself as the actor, on behalf of the original user, to call a downstream MCP.
Agent A → AS POST /oauth/token
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
subject_token=<user's JWT>
subject_token_type=urn:ietf:params:oauth:token-type:access_token
actor_token=<agent A's token> ← optional
resource=https://api.example.com/downstream
scope=tools/read
AS → Agent A 200 { access_token, token_type, issued_token_type }
The issued token carries sub = original user, and an act claim naming the actor:
{
"sub": "user-42",
"act": { "sub": "agent-A", "actor_type": "agent" }
}
Chained exchanges (A → B → C) nest: act = the outermost actor, act.act = the actor before them. Full chain reconstructable from the token alone. See Concepts: Delegation & act-chain.
Vending upstream tokens (Broker resources)
Same grant, different target — the MCP server names an upstream provider (resource=github) and gets back a bearer token for the user’s GitHub account:
MCP → AS POST /oauth/token
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
subject_token=<user's JWT>
resource=github
scope=repo
client_id=<MCP server client_id>
client_secret=<MCP server secret>
AS → MCP 200 { access_token: "gho_xxx", ← GitHub's own token
token_type: "Bearer",
expires_in: 3600 }
AuthPlane enforces the three-bound consent model: requested_scopes ⊆ consent_grants ⊆ broker_grants. If the user never connected GitHub, or granted narrower scopes, you get error=consent_required with a consent_url — the SDK translates this to MCP -32042 elicitation.
Enable it: token_exchange.enabled: true or AUTHPLANE_TOKEN_EXCHANGE_ENABLED=true.
Deep dive: Concepts: Token Vault and Guides: Wire up the Token Vault.
JWT bearer — Cross-App Access (XAA)
Your enterprise IdP (Okta, Entra ID, Auth0) signs a JWT asserting the agent’s identity — an “ID-JAG” (identity JWT-authorization grant). Your MCP client presents that assertion to AuthPlane, which validates it against the IdP’s registered JWKS and mints an AuthPlane token. Skips per-user consent for policy-approved agents.
Agent → AS POST /oauth/token
grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
assertion=<signed JWT from your IdP>
client_id=<agent client_id>
resource=https://api.example.com/mcp
scope=tools/read
AS → Agent 200 { access_token, expires_in, token_type }
Key details:
- Enable it: requires XAA —
xaa.enabled: true. Then register a trusted IdP via the admin REST API (POST /admin/idps; no CLI subcommand). - Assertion validation — signature verified against the IdP’s JWKS (cached, TTL configurable),
iss/aud/exp/nbf/iatchecked, replay guarded viajtistore. - Subject mapping —
auto_map(default) uses the assertion’ssubdirectly;strictrequires an explicitxaa_subject_mappingsrow and rejects unmapped subjects. - Policy engine — optional.
POST /admin/xaa/policies(admin REST API; no CLI subcommand) lets you allow/deny mints based on assertion claims (department, group membership, agent name, etc.). - Client must be registered with
jwt-beareringrant_types— DCR-registered clients don’t get this by default.
Deep dive: Concepts: Cross-App Access (XAA), Guides: Enterprise-Managed Auth, and Topologies: Enterprise-Asserted Agent Identity.
Which grant should I use?
Ask two questions.
“Is a human authenticating?”
- Yes, and they’ll consent in a browser → authorization code + PKCE (with refresh for renewal).
- Yes, and my corporate IdP handles it → authorization code + PKCE with OIDC federation; no code change, AuthPlane delegates login upstream.
- No, a service is authenticating as itself → client credentials.
- No, my corporate IdP is asserting the agent’s identity → JWT bearer (XAA).
“Do I need to reach a third party on the user’s behalf?”
- No, I just need my own JWT for my MCP server → authorization code (users) or client credentials (machines).
- Yes, I have the user’s AuthPlane token and want a narrower AuthPlane token for a downstream MCP → token exchange (delegation).
- Yes, I have the user’s AuthPlane token and want their GitHub / Slack / Google token → token exchange with a Broker resource.
Combining grants
Real deployments use several at once:
- User agent → authorization code (user login) → refresh (silent renewal) → token exchange to a Broker (vend upstream token when a tool needs GitHub).
- Enterprise deployment → OIDC federation (user login via Okta) → authorization code → token exchange to Broker (Slack).
- Enterprise agent with XAA → JWT bearer (Okta signs ID-JAG for agent) → token exchange (delegate to sub-agent).
- Background worker → client credentials → token exchange to a Broker (if the worker needs upstream API access via a stored user grant — the service-account-user pattern).
Related
- Concepts: Architecture — where each grant lives in the codebase
- Concepts: DPoP — sender-constrained variants of every grant above
- Concepts: Delegation & act-chain — deeper on RFC 8693 act semantics
- Concepts: Token Vault — Broker resources and the three-bound consent model
- Concepts: Cross-App Access (XAA) — JWT bearer + policy engine end to end
- Reference: RFC compliance — coverage matrix for every RFC listed on this page