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

GrantWho authenticatesTypical useRFC
Authorization code + PKCEA human, in a browserAn MCP agent needs a user token to call your MCP serverRFC 6749 §4.1 + RFC 7636
Refresh tokenThe client, with a token issued to it earlierSilent token renewal without another consent screenRFC 6749 §6
Client credentialsThe client, with its own client_id + client_secretA backend worker, CI pipeline, or automated agent — no userRFC 6749 §4.4
Token exchangeA client presents a token issued to someone elseOn-behalf-of delegation; vending an upstream provider tokenRFC 8693
JWT bearerAn external IdP signs an assertion; client presents itEnterprise-asserted agent identity (XAA)RFC 7523

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).
  • resource parameterRFC 8707 resource indicator. AuthPlane binds the token’s aud claim to this URI. Your MCP server should reject tokens whose aud doesn’t include its own URI.
  • state parameter — 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 DPoP header on /oauth/token, the resulting token gets token_type: "DPoP" and a cnf.jkt claim 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: true or AUTHPLANE_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.
  • sub claim = 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 DPoP header at /oauth/token to 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/iat checked, replay guarded via jti store.
  • Subject mappingauto_map (default) uses the assertion’s sub directly; strict requires an explicit xaa_subject_mappings row 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-bearer in grant_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).