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 with act-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

TokenFormatLifetimePurpose
Access tokenJWT (at+jwt)15 minMCP server authorization
Refresh tokenOpaque (random string)7 daysRenew access tokens
Machine tokenJWT (at+jwt)1 hourService-to-service (no user)
Exchanged tokenJWT (at+jwt)15 min – 1 hourDelegation with act chain
Auth codeOpaque (random string)10 minOne-time code → tokens

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:

  1. Fetch AuthPlane’s JWKS (cache; refresh every 5 min).
  2. Verify signature with the key matching kid.
  3. Check iss = your configured AuthPlane issuer.
  4. Check aud contains your resource URI.
  5. Check exp > now, nbf ≤ now (with clock_skew_seconds leeway, default 30 s).
  6. Check scope contains 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_tokens table with family FK; broker_grants table for upstream refresh grants (encrypted).
  • Auth code: stored in auth_sessions with consumed_at for atomic single-use.

Machine tokens (client credentials)

Same JWT format as access tokens, with two differences:

  • sub = the client_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"
  • cnf claim added: {"jkt": "<base64url SHA-256 of client's public JWK>"}
  • Authorization: DPoP <token> scheme on requests (not Bearer)
  • Client must send a fresh DPoP proof per request

Algorithm restrictions (always):

  • ES256, RS256, PS256 — accepted
  • HS256, HS384, HS512 — always rejected (symmetric — verifier would need signing key)
  • alg: none — always rejected (unsigned proofs trivially forgeable)
  • Private key in jwk header — 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/token must hash to this
  • client_id and redirect_uri — exact match required
  • resource — flows to aud on 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 jti to trace one token end-to-end.
  • RevocationPOST /oauth/revoke marks the jti revoked (RFC 7009).
  • IntrospectionPOST /oauth/introspect returns active: false after revocation (SDKs can enable this via revocation_checker).
  • DPoP proof replay detection — separate jti on the DPoP proof itself, stored in dpop_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.