Token design
TL;DR — AuthPlane issues five token types. Access tokens are JWTs (RFC 9068) — 15 min, verified locally against JWKS. Refresh tokens are opaque strings — 7 days, revocable per family, mandatory rotation. Machine tokens are JWTs with
sub = client_id, 1h, no refresh. Exchanged tokens are JWTs withact-chain — 15 min or configurable. Auth codes are opaque, single-use, 10 min. Every design choice was picked over an alternative for a specific security reason; this page walks through each.
The five token types at a glance
Access tokens (JWT, RFC 9068)
The primary credential your MCP server sees.
Header:
{ "typ": "at+jwt", "alg": "ES256", "kid": "key-2026-02" }
Payload:
{
"iss": "http://localhost:9000",
"sub": "user-uuid-v7",
"aud": ["http://mcp-server:3000/mcp"],
"exp": 1708762500,
"iat": 1708761600,
"nbf": 1708761600,
"jti": "token-uuid-v7",
"client_id": "client-uuid-v7",
"scope": "tools/echo tools/query_database"
}
DPoP-bound tokens add cnf.jkt (base64url thumbprint of the client’s public key). AuthPlane extensions add agent_id, agent_chain, act when applicable.
How your MCP server verifies:
- Fetch AuthPlane’s JWKS (cache; refresh every 5 min).
- Verify signature with the key matching
kid. - Check
iss= your configured AuthPlane issuer. - Check
audcontains your resource URI. - Check
exp> now,nbf≤ now (withclock_skew_secondsleeway, default 30 s). - Check
scopecontains what your tool requires.
Every AuthPlane SDK does all this in one call. See SDKs overview.
Why 15 minutes?
Short lifetime = small blast radius if leaked. Long enough that most requests use a valid access token (no refresh dance per call). Refresh is fast — 1 network hop, no user interaction. Bumping to 1h+ starts to matter for security posture on multi-hop deployments where a leaked token means an hour of unauthorized access.
Refresh tokens (opaque)
Opaque random strings, NOT JWTs. Stored server-side.
Why opaque instead of JWT?
- Revocable per-family — refresh tokens are grouped into “families” (originally, this issuance and its rotations). Detected theft revokes the entire family in one DB update.
- No self-decoding — a leaked opaque handle reveals nothing about the user; a leaked JWT reveals every claim.
- Server-side state — each rotation writes a new row and revokes the old one atomically.
Mandatory rotation (RFC 9700 §4.14)
Every refresh_token grant returns a new refresh token and invalidates the presented one. If the same refresh token is used twice, the entire family is revoked — every access token and refresh token derived from it is invalidated immediately.
This is why authserver_refresh_token_reuse_total > 0 is a critical alert — every trigger means either theft or a client bug that will lock the user out.
Storage
- Access token: not stored (stateless JWT).
- Refresh token: stored in
refresh_tokenstable with family FK;broker_grantstable for upstream refresh grants (encrypted). - Auth code: stored in
auth_sessionswithconsumed_atfor atomic single-use.
Machine tokens (client credentials)
Same JWT format as access tokens, with two differences:
sub= theclient_id(not a user UUID) — tells MCP servers “this is machine identity”.- No refresh token — machines re-authenticate every hour by re-presenting their client secret.
Why no refresh token for machines?
- Machines can re-authenticate instantly (they hold the secret).
- Smaller blast radius on token leak (max 1h).
- Simpler rotation (no family tracking).
- Forced re-auth every hour surfaces secret rotation immediately.
Machine tokens support DPoP the same way user tokens do — send a DPoP header on /oauth/token to get token_type: DPoP and cnf.jkt.
DPoP-bound tokens
Any of the above token types can be DPoP-bound (RFC 9449). Only what changes:
token_type: "DPoP"instead of"Bearer"cnfclaim added:{"jkt": "<base64url SHA-256 of client's public JWK>"}Authorization: DPoP <token>scheme on requests (notBearer)- Client must send a fresh DPoP proof per request
Algorithm restrictions (always):
ES256,RS256,PS256— acceptedHS256,HS384,HS512— always rejected (symmetric — verifier would need signing key)alg: none— always rejected (unsigned proofs trivially forgeable)- Private key in
jwkheader — always rejected (would leak private key)
Deep dive: Security: DPoP.
Exchanged tokens (RFC 8693)
Same JWT format, plus an act claim describing the acting party:
{
"sub": "user-42", // preserved from subject
"aud": "https://downstream.example.com",
"scope": "tools/read",
"act": {
"sub": "agent-A", // who's acting on user's behalf
"actor_type": "agent"
}
}
Chained exchanges (A → B → C) nest: act.act = previous actor. Full delegation chain reconstructable from the token alone; RFC 8693 §4.1 ¶6 says only the outermost actor is authoritative for access-control decisions.
act.act.sub inner values are only as trustworthy as the issuer of the original subject token — AuthPlane only accepts its own tokens on the exchange path today, so every inner-hop value was stamped by AuthPlane on a prior exchange.
Deep dive: Concepts: Delegation & act-chain.
Auth codes (opaque)
Opaque, 10-minute lifetime, atomic single-use (UPDATE ... WHERE consumed_at IS NULL RETURNING). Bound to:
code_challenge(PKCE-S256) — verifier presented at/oauth/tokenmust hash to thisclient_idandredirect_uri— exact match requiredresource— flows toaudon the eventual JWT
Attempted reuse triggers invalid_grant "authorization code has already been used" and revokes the token family issued from the successful exchange.
jti semantics
Every JWT-format token has a jti (UUID v7 — monotonic sortable). Uses:
- Correlation — grep logs by
jtito trace one token end-to-end. - Revocation —
POST /oauth/revokemarks thejtirevoked (RFC 7009). - Introspection —
POST /oauth/introspectreturnsactive: falseafter revocation (SDKs can enable this viarevocation_checker). - DPoP proof replay detection — separate
jtion the DPoP proof itself, stored indpop_nonces.
Why no ROPC (Resource Owner Password Credentials)
OAuth 2.1 §1.3 defines only authorization-code, refresh, and client-credentials — ROPC is removed from the spec, not merely deprecated. It teaches users to hand their password to random clients, breaks 2FA, and provides no delegation semantics. AuthPlane doesn’t implement it. If you need machine auth, use client_credentials. If you need federated login, use oidc.enabled or XAA.
Why no Implicit grant
Same story — OAuth 2.1 §10.1 removes implicit (“omitted from OAuth 2.1”), it isn’t just deprecated. Auth code + PKCE covers every use case implicit grant addressed, with better security.
Related
- Concepts: Grants & flows — how each token type ties to a grant
- Security: DPoP — proof-of-possession deep dive
- Security: Key management — how the JWT signing key lifecycle works
- Reference: RFC compliance — every RFC referenced above with coverage
- Full source in the authserver repo