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 8693act 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:
agtlists the agent ids on the authority path, root first. Every one must stayactiveand unexpired.lnklists the delegation link id for each consecutive pair inagt.agent_id(andprincipal.agent) is the acting agent, the last entry ofagt. A bound token whoseagent_idis not inagtis 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 nextrevalidate()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.
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 therevocation 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 intheauth_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:
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 presentcnf,act,agent_idandtheauth_dlg. - Inactive token, entitled caller:
{ "active": false, "theauth_inactive_reason": "delegation_revoked" }. The reasons areexpired,revoked,subject_revoked,client_revoked,delegation_revoked,delegation_expired,agent_revoked,agent_expiredandinvalid. - Any other caller: exactly
{ "active": false }.
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
PassonRevoked in the mcp.revocation config of createTheAuth to forward revocations to a Shared Signals transmitter:
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:
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 ajtirow in the denylist. It does not see a revoked parent token, link, agent or user until you callrevalidate(). 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
suborclientwatermark 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
requireBoundChainif that is not acceptable. - Tokens minted before the upgrade. They carry no
theauth_dlg. Withactand nodelegationStorethey are trusted untilexp; tokens with anagent_idget a single agent status check. Exchanged tokens cannot refresh, so the gap closes withinaccessTokenTtl, 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 forgedtheauth_dlgor 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 hassigningSecretconfigured 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.jktsays the caller is the holder.
Related
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.