Skip to main content
An MCP access token is a signed JWT. A resource server can check the signature and exp and never talk to theAuth again, which is fast and also means a token outlives the thing that justified it. This page explains how theAuth closes that gap, how to choose a freshness mode, and what it does not cover. The short version: with a database and the default settings, revoking a delegation link, an agent, a single token or a refresh token family makes the next MCP request that carries an affected token fail on every theAuth instance that shares the database. Signing a user out everywhere, banning, deleting or erasing a user, and deprovisioning through SCIM do the same for that user’s tokens, and the token endpoint refuses to mint new ones from refresh tokens or authorization codes issued before the revoke. Some paths are not covered, and the Limits section lists them. A request that already passed validation finishes.

What a token carries

A token minted by token exchange keeps the RFC 8693 act claim, nested with the current actor outermost. It also carries a private claim, theauth_dlg, that ties the token to the exact records its authority rests on:
  • agt lists the agent ids on the authority path, root first. Every one must stay active and unexpired.
  • lnk lists the delegation link id for each consecutive pair in agt.
  • agent_id (and principal.agent) is the acting agent, the last entry of agt. A bound token whose agent_id is not in agt is invalid.
  • theauth_pjti (exchanged tokens only) lists the jtis of the subject token lineage, root first. Revoking any of those tokens, or the refresh family behind one, revokes this token too: on the next live call, within the poll window in cached mode, and on the next revalidate() in offline mode.
  • Exchange refuses (invalid_grant) a child naming an agent the subject user neither owns nor reaches over live delegation links from an agent they own.
The claim is signed, so a resource server can trust it only after verifying the signature. It holds ids and nothing else: no permissions, no names. Tokens are bound when their authority resolves to agent rows: token exchange, and authorization code, refresh, client credentials and jwt-bearer tokens that carry agent_id. An ordinary token gets the agent’s live inbound path up to its root, agt: [root, ..., agentId] with one lnk per hop, or agt: [agentId] and an empty lnk when nobody delegated to it. The path is capped at 8 hops above the agent, and the validator still checks the root’s own inbound link live. Because every link is in the claim, revoking a delegation above the agent is seen in cached mode and on revalidate() in offline mode, not only in live mode. A token with no agent behind it, such as a plain user to service token, is unbound. Only token, subject and client revocation apply to it, because there is no delegation record to revoke. A token also never outlives its sources. At mint time exp is the earliest of the configured lifetime, the subject and actor token expiry, every link’s expiry and every agent’s expiry on the path. That is a cap on exp, so it applies whatever freshness mode you run. It is not a revocation: revoking the parent token is a separate rule, covered by theauth_pjti above.

Freshness modes

The mode decides what a resource server validation reads. Set it in the revocation block of the MCP config:
The default is live when you pass auth to createMcpModule (a shared database), and offline when you pass your own stores, because there is nothing shared to read. The environment variable THEAUTH_MCP_REVOCATION_MODE sets the mode when the config does not. Three things hold in every mode. The token endpoint checks live for the refresh token and authorization code grants: a refresh token or code whose jti, subject (sign out everywhere, ban, delete), client (CIMD block) or bound agent was revoked is refused with invalid_grant, and a store error fails closed with server_error. Token exchange validates the subject and actor tokens live. Introspection always checks live. Expiry is always honored at mint time. The client_credentials and jwt-bearer grants do not read the revocation table before they mint (see Limits). Live mode costs a handful of primary key or indexed lookups on requests that are usually tool calls, so most deployments should leave it on. Move to cached above roughly a thousand validations per second per instance:
revokeOnSignOutEverywhere, clockSkewMs and onRevoked are read from the mcp config you pass to createTheAuth, because the revocation bus and the database store are built there. If you pass mcp only to createMcpModule, no sub rows are written and sign out everywhere does nothing for MCP tokens. Pass the same config object to both, as the MCP page does. clockSkewMs widens the sub and client watermark by that many milliseconds, so a revoker whose clock lags still kills tokens minted just before the revoke. The cost is that a token minted within that window after a revoke is also refused. It defaults to 0, which assumes your instances keep NTP time. Set it (2000 is a reasonable value) if they do not. In cached mode a process that performs the revoke clears its cache at once. Other instances compare a fingerprint of the revocation table (the newest revoked_at_ms and the number of rows near it) on each poll and clear when it changes. If a revocation row failed to write, or an agent expiry was shortened by hand, the cache entry still dies at maxStalenessMs. In offline mode, shorten accessTokenTtl (300 seconds is a reasonable start). Exchanged tokens cannot be refreshed, so the lifetime is the whole bound. A store error during a live or cached check rejects the token with a server error. It never falls back to offline.

The shared denylist and migration 0007

Revocations are rows in theauth_mcp_revocations, created by migration 0007. The same migration adds indexes on theauth_delegation_chains for from_agent_id and to_agent_id, which the delegation liveness walk needed. Run theauth db up (or let the instance migrate on start). With skipMigrations: true, generate the SQL with theauth db sql --from 0007. See Database. A row never expires less than 60 seconds after it is written, so a row for an already expired link outlives clock skew. Rows for things with no expiry last 7 days unless THEAUTH_MCP_REVOCATION_RETENTION_MS sets another value. A jti row is skipped when the token expired more than a minute ago. sub and client rows are watermarks on iat. Validation reads both kinds (a hit reports client_revoked). mcp.cimd.block writes a client row for the blocked client when the denylist is the shared store, so its tokens are refused at once in live mode on every instance (see Client ID Metadata Documents). You can also write one yourself through the store. A token minted in the same second as the revoke also dies, which fails closed. Expired rows are pruned after writes, in batches of 500 and at most four batches per run, and at most once per pruneIntervalMs per instance (60 seconds by default). There are no timers, so pruning only happens on an instance that writes. For a cron job or a read only instance, call auth.revocations.prune(), which deletes expired rows now and resolves to the number deleted. The delegation and agent tables stay the source of truth for live checks. The rows exist to speed up the fail path and to drive cache invalidation across instances. With auth, config.jtiDenylist defaults to this shared store, so a token revoked on one instance is refused on all of them. An explicit jtiDenylist always wins. With createInMemoryJtiDenylist() the denylist is per process.

What pushes a revocation

The sub row is a watermark on iat. admin.unbanUser does not bring back tokens issued before the ban, and the owner’s agents stay revoked. Revoking a single session does not touch MCP tokens, because they are not tied to a session. Signing out does not delete refresh token families either. The live check at the token endpoint is what stops a refresh token after a sign out everywhere. A push is retried once. If the retry also fails, the failure is logged with the revoked ids (never a token) and counted: auth.revocations.stats() reports pushFailures for pushes that failed after the retry and attemptFailures for every failed attempt. The revoke itself continues: the link or agent row is already revoked, so live mode still sees it and cached mode falls back to its staleness bound. The instance clears its own cache before it writes and again after. If the row for an explicit token revoke fails to write, the revoke call fails (the HTTP route answers 503 temporarily_unavailable, described below) instead of reporting success, because that row is the only record. Pushes work on runtimes without process, such as Cloudflare Workers without nodejs_compat and Deno. An agent whose expiresAt passes is not pushed. Expiry is read from the agent row on live checks and capped into exp at mint.

Introspection and revocation endpoints

POST /mcp/revoke is RFC 7009. A client may revoke its own tokens, and a client_id mismatch answers 200 without doing anything, as the RFC asks. An unknown token also answers 200. When the revocation store cannot record the revoke, the endpoint answers 503 with Retry-After and { "error": "temporarily_unavailable" } (RFC 7009 section 2.2.1) instead of a 500. This instance still treats the token as revoked locally (its freshness cache is dropped), but other instances learn about it only after a successful retry, so the client should retry. POST /mcp/introspect is RFC 7662, for resource servers that cannot or should not trust the JWT alone. It is off until you enable it:
When enabled and mounted, the metadata advertises introspection_endpoint. The route is mounted by default and answers 404 until enabled is true. If you mount a subset with routes: { mcp: { ... } }, include introspect: true. The caller must be a confidential client and authenticate with client_secret_basic, client_secret_post or private_key_jwt. Public clients are refused with 401 invalid_client. No token parsing happens before that check. The answer is always live. Only access tokens are introspectable, and refresh tokens report active: false. A token longer than 8192 characters is inactive without any cryptography. Responses carry Cache-Control: no-store. Who sees what:
  • Active token, entitled caller: scope, client_id, sub, exp, iat, aud, iss, jti, token_type, and when present cnf, act, agent_id and theauth_dlg.
  • Inactive token, entitled caller: { "active": false, "theauth_inactive_reason": "delegation_revoked" }. The reasons are expired, revoked, subject_revoked, client_revoked, delegation_revoked, delegation_expired, agent_revoked, agent_expired and invalid.
  • Any other caller: exactly { "active": false }.
A caller is entitled when it is the token’s own client_id, or a resourceServers entry whose audiences include the token’s aud. A resource server that is not listed learns nothing about why a token is dead, and cannot tell a revoked token from one it was never entitled to see. There is no default audit row. Pass introspectionAudit to createMcpModule to receive the caller client, the jti, the result and the reason after each decision of an authenticated caller. The token is never passed to it.

Shared Signals events

Pass onRevoked in the mcp.revocation config of createTheAuth to forward revocations to a Shared Signals transmitter:
Here signals is the result of sharedSignals(...). dlg, agent and sub revocations with a known user become CAEP session-revoked events, with the revoke reason in reason_admin and a session id of the form <kind>:<id>. A jti revocation sends nothing, because there is no user worth naming. Delivery is asynchronous and errors are swallowed. This is event emission, not a CAEP or SSF conformance claim. See Standards.

Tightening exchange

By default a token exchange whose actors resolve to no agent row mints an unbound token (act only). Set requireBoundChain: true to refuse it with invalid_grant, so every delegated token is revocable through its delegation:
When a binding store is attached (the default with auth), exchange also refuses a chain where two consecutive agent actors have no live delegation link between them, whatever this setting says. With custom stores and no binding store, nothing is checked. Exchange also checks ownership. Every agent the child token names must be one the subject user owns, or one reached over live delegation links from an agent they own. Otherwise exchange answers invalid_grant and writes a denied mcp.token_exchange audit row under the refused agent.

Long running handlers

Validation happens once, before the handler runs. A tool call that takes minutes, or an SSE stream, would otherwise outlive a revoke. requireMcpAuth hands the handler a session whose revalidate() repeats the revocation and binding checks live:
revalidateEveryMs throttles the store reads: a call within that window of the last check returns success without querying. It is off by default, and without it revalidate() always checks.

Limits

These are the places revocation does not reach. Plan around them.
  • Offline resource servers. A server that verifies the JWT itself, or that caches introspection answers, sees a revoke when its cache ends or the token expires. Only live validation and uncached introspection are immediate.
  • Calls in progress. A request validated before the revoke commits completes. Streams and long handlers are cut off only if they call revalidate().
  • Offline and cached freshness. Offline mode trusts the token until exp, apart from a jti row in the denylist. It does not see a revoked parent token, link, agent or user until you call revalidate(). Cached mode has the poll delay described above.
  • Sign out and refresh tokens. Signing out does not delete refresh token families. The live check at the token endpoint refuses the refresh, but the family rows stay until they expire.
  • client_credentials and jwt-bearer. These grants mint without reading the revocation table. A sub or client watermark kills tokens minted before it, and does not stop these grants minting a new token afterward. An agent bound client is still refused once its agent is revoked or expired.
  • Authorization code and another user’s agent. The authorization code path refuses a revoked or expired agent, but it does not yet refuse a client that an admin bound to another user’s agent. This is an open policy decision.
  • Unbound tokens. A token whose actors are not theAuth agents has no delegation to revoke. Use requireBoundChain if that is not acceptable.
  • Tokens minted before the upgrade. They carry no theauth_dlg. With act and no delegationStore they are trusted until exp; tokens with an agent_id get a single agent status check. Exchanged tokens cannot refresh, so the gap closes within accessTokenTtl, one hour by default.
  • HS256 shares the secret. With signingSecret, any resource server that holds the secret can sign a token, including one with a forged theauth_dlg or none. Use RS256 or ES256 signing keys (see the MCP page) for resource servers you do not fully control, and give them the public key only. Setting signing keys is not enough: a server that still has signingSecret configured accepts HS256 tokens, so remove it from those servers.
  • No push on expiry. A shortened expiry is seen by live and cached modes and not by offline. Nothing is pushed when an agent simply expires.
  • Replicas lag. Live checks must read the primary. Pointing them at a lagging read replica adds the lag to every bound above.
  • Custom stores. With your own stores and no database, or with the in-memory denylist, there is no cross instance freshness.
  • Replay. Binding does not stop a stolen live token being used. DPoP does. The two are independent: the binding says the authority is alive, cnf.jkt says the caller is the holder.

MCP OAuth 2.1

Setup, endpoints and token validation.

Delegation

Links, cascades and agent liveness.

DPoP

Sender constrained tokens.

Upgrading to 2.0

What changes for existing integrations.
Last modified on October 18, 2026