Token Vault

TL;DR — When your MCP server needs to call GitHub / Slack / Google / Linear on a user’s behalf, the naïve approach is to store each user’s refresh token per MCP. AuthPlane centralizes this: the user connects each upstream provider once via the Connect flow, AuthPlane encrypts the refresh grant, and any authorized MCP server vends a short-lived upstream token on demand via RFC 8693 token exchange. Three-bound consent gates every vend. “Token Vault” is the mental name; the implementation is the unified Resource model with backend_kind: brokermint resources are AS-signed, broker resources vend upstream tokens.

The problem it solves

Your MCP server “GitHub PR reviewer” needs to make GitHub API calls on the user’s behalf. Without central credential management:

  1. Every MCP server implements its own OAuth flow with each upstream.
  2. Every MCP stores refresh tokens somewhere (usually badly — plaintext DB, leaked logs).
  3. When a user revokes access at GitHub, every MCP is a stale reference.
  4. Auditing “which agent called what upstream on my behalf” is spread across N systems.

The Token Vault pattern moves credential storage + refresh + audit to one place — AuthPlane. Every MCP server calls one endpoint (/oauth/token with token-exchange grant), gets a fresh short-lived upstream token, forwards it, done.

Mental model — Mint vs Broker

Every AuthPlane resource has a backend_kind:

  • mint — AuthPlane signs its own JWT for the resource. Standard OAuth resource server.
  • broker — AuthPlane vends a token minted by an upstream provider. AuthPlane doesn’t sign the vended token — GitHub does. But AuthPlane holds the stored credential + orchestrates consent + audits the vend.

broker resources have a broker_provider_slug pointing at a registered upstream OAuth provider. The provider’s config includes the OAuth client credentials AuthPlane uses when running the Connect flow with the upstream.

The full picture

flowchart TD
    Connect(["Connect flow (one-time)"])
    AuthPlane["AuthPlane<br/>broker_grants<br/>per-user<br/>encrypted at rest<br/>(AES-GCM or Vault Transit)"]
    MCP["Your MCP Server<br/>(tool handler)"]
    IdP["Upstream IdP<br/>(GitHub)"]
    API["Upstream API<br/>(github.com)"]

    Connect -->|writes here| AuthPlane
    AuthPlane -->|exchange call| MCP
    IdP -->|upstream OAuth token endpoint| AuthPlane
    MCP ---|"upstream bearer (Bearer gh_xxx)"| IdP
    MCP --> API

Two flows:

  • Connect flow (one-time per user × provider) — user browses to /connect/github, approves at GitHub, AuthPlane stores the encrypted refresh grant in broker_grants. Never plaintext on disk.
  • Vend flow (per request) — your MCP calls /oauth/token with token-exchange grant naming a broker resource. AuthPlane checks the three-bound consent, uses the stored refresh grant to get a fresh upstream token, returns it. Your MCP forwards it.

Every vend is gated by three bounds:

requested_scopes ⊆ consent_grants.scopes        (per-agent attestation)
                 ⊆ broker_grants.scopes_granted  (per-provider grant)

Concretely, five checks:

GateCheckFailure
AScopes are recognized for this broker resourceinvalid_scope
Bconsent_grants row exists for (user, agent, resource)consent_required (AS-side re-consent URL)
Crequested ⊆ consent_grants.scopesconsent_required (AS-side re-consent URL)
Dbroker_grants row exists for (user, provider)consent_required (upstream /connect/{provider} URL)
Eupstream-mapped scopes ⊆ broker_grants.scopes_grantedconsent_required (upstream re-connect URL)

The cause sub-discriminator (consent_missing vs scope_insufficient) tells the SDK which consent_url to attach. All of these translate to MCP JSON-RPC -32042 UrlElicitationRequiredError in your tool code — your handler doesn’t need try/except.

Vending is never cached

Every /oauth/token for a broker resource hits the upstream IdP (or the stored refresh grant if the current AT is still fresh). AuthPlane doesn’t hold upstream access tokens in memory — the vended token flows through and is your MCP’s responsibility until it expires.

Concurrent vends are serialized via optimistic locking. Two simultaneous vends that both need refresh → the second gets HTTP 423 Locked. SDK clients retry once on 423.

What happens on upstream revocation

If the user revokes AuthPlane’s OAuth app at GitHub, the next refresh attempt gets rejected. AuthPlane responds 400 consent_required cause=consent_missing consent_url=/connect/github. Client walks the user through re-connecting; the encrypted broker_grants row is replaced.

Why encrypted at rest

The data_encryption driver (AES-256-GCM or Vault Transit) is a hard requirement when broker_providers are configured — boot fails otherwise. Stored plaintext refresh grants are a compliance and blast-radius nightmare; AuthPlane refuses to run without encryption.

  • aes_master — local AES-256-GCM with HKDF-derived per-purpose subkeys. Master key via env var (AUTHPLANE_DATA_ENCRYPTION_KEY).
  • vault_transit_encrypt — plaintext never touches the AuthPlane process; delegated to Vault Transit.

Retirement of “Token Vault” terminology

Historically this was called “Token Vault”. The name was retired in v0.1.0-rc1 in favor of the unified Resource model — same on the wire, cleaner mental model. The broker_grants table name reflects the current terminology; the vault_transit_* driver names refer to HashiCorp Vault Transit (an external KMS) and are unrelated.