Skip to main content
A signed token is valid until it expires. That is the problem with giving an agent one: you cannot take it back. theAuth ties each delegated MCP token to the delegation records it rests on, so revoking the delegation kills the token on the next request. This tutorial runs that end to end in one process with in memory SQLite. There is no server, no model and no API key. The script is in examples/mcp-revocation-demo. For the reference behind it, see MCP token revocation.
Run both from the repo root. The example directory has no package.json, so it imports the built package by relative path. In your own project, import from @glinr/theauth and @glinr/theauth/mcp.

What you build

1. Create the agents, the delegation and the token

Alice owns an assistant that can read and write reports. The assistant delegates read only to a sub-agent, which has no permissions of its own.
Delegation links connect agents, but OAuth tokens belong to clients. So each agent gets an OAuth client, bound once with mcp.clients.bindAgent(clientId, agentId). Alice approves the assistant app through the authorization code flow, which gives a token for mcp:read mcp:write whose agent_id is the assistant. The sub-agent app then trades that token for a narrower one with RFC 8693 token exchange:
Exchange can only narrow. The exchanging client is bound to the sub-agent, so theAuth looks up the live link from the assistant to the sub-agent and writes its id into the token. That link id is what revocation will pull on later. Exchange also checks ownership: every agent the new token names must be one Alice owns, or one reached over live links from an agent she owns. Otherwise it answers invalid_grant. The MCP module reads revocation state in one of three modes. The default with a database is live, and this script runs the issuing module that way.

2. A call inside the scope

The helper callTool is what an MCP server does for each request: mcp.requireScopes(request, [scope]), then one audit row for the outcome.
read_report needs mcp:read, which the token has, so the answer is 200.

3. A call outside the scope, and why

delete_report needs mcp:write. The token was narrowed to mcp:read, so requireScopes answers 403 INSUFFICIENT_SCOPE and the tool never runs. auth.explain() then says why the sub-agent could not do this, without writing an audit row. The sub-agent holds one delegated grant, read on mcp:reports:*. Nothing matches a write, so the decision is deny with code no_permission. The useful part is nearMisses[0]: the delegated grant matched the resource and failed only on the action, and the hint says so in one sentence. See Explain a decision.

4. Read the token

The script decodes the JWT payload and prints the parts that matter. It never prints the raw token.
  • act is the RFC 8693 actor claim. Here it holds one entry, and its sub is the OAuth client id of the sub-agent app. The script labels it for you.
  • agent_id is the sub-agent, the acting agent and the last entry of agt.
  • theauth_dlg lists agt, the agents on the authority path with the root first (assistant, then sub-agent), and lnk, the delegation link between them. This signed claim is the binding. It holds ids only, no permissions or names.
  • theauth_pjti lists the jtis of the token this one was exchanged from. The script does not print it. Revoking the assistant’s token would revoke this one too.

5. Revoke the parent delegation

The token has not expired and its signature is still valid. Nothing was sent to the agent. The revoke marks the link as revoked, writes a delegation.revoke audit row that names Alice, and records the link in the shared revocation table.

6. The next request fails

Same token, same tool. The resource server checks the signature, then the binding: is every agent in agt still active, and is every link in lnk still live? The link is revoked, so the answer is 401 DELEGATION_REVOKED. The script prints the audit rows for Alice’s two agents in write order: the allowed read, the denied write, the revoke, and the denied read after it. audit.query returns newest first, so the script reverses the list. One detail in that trail: the denial rows land under the sub-agent, because the server attributes a rejection to check.rejected.agentId, which comes from the token’s agent_id claim. The delegation.revoke row is on the delegate’s chain, which is also the sub-agent.

7. The honest tradeoff: cached and offline

Live mode reads the database on every request. That is a handful of indexed queries, and the count grows with the length of the delegation path. Two other modes skip some or all of that, and the script shows what you give up. Cached. The script builds a second MCP module with revocation: { mode: "cached", pollIntervalMs: 200, maxStalenessMs: 1000 }. It is given only the database handle, with no in process revocation bus, so it learns about a revoke the way a separate server instance would: by polling the shared table. The call right after the revoke still answers 200, because the cache holds the earlier verdict. The script then retries until the poll lands and the answer turns into 401. The documented bound is maxStalenessMs, which defaults to 5000 and was lowered here so the run is short. The process that performs the revoke clears its own cache at once, so you only see this gap on other instances. Offline. A third module with mode: "offline" checks the signature and exp and nothing else. The script revokes, waits 1.5 seconds, and the call still answers 200. The token stays good for about 60 minutes, because accessTokenTtl is 3600 seconds here. Nothing shortens that, and a server in this mode would not see a revoked parent token, agent or user either, unless the handler calls revalidate(). If you use offline mode, set accessTokenTtl to a few minutes. The script then sends the same token to the live module, which refuses it. Pick the mode by what a stale answer costs. The table in MCP token revocation has the numbers.

8. Ask the issuer

A resource server that does not want to trust the JWT alone can call the introspection endpoint (RFC 7662). It always reads live state, in any mode. The caller must be a confidential client. The module config lists which clients may introspect which audiences:
The listed server orders-api gets {"active":false,"theauth_inactive_reason":"delegation_revoked"}. A caller that is not listed gets {"active":false} and nothing else, so it cannot tell a revoked token from one it was never meant to see.

Expected output

IDs and timestamps are left out, so two runs print the same text. The cached step takes a few hundred milliseconds. The offline step waits 1.5 seconds on purpose.

Where this stops

  • Offline mode trusts the token until exp. Cached mode has the poll delay you saw in step 7a.
  • Banning or deleting a user, and signing out everywhere, revoke that user’s tokens too, and the token endpoint refuses their refresh tokens and authorization codes. Signing out does not delete refresh token families. The live check at the token endpoint is what stops the refresh.
  • client_credentials and jwt-bearer tokens are not covered by the user and client watermarks, because those grants do not read the revocation table before minting. Agent bound tokens are still refused once the agent is revoked.
  • The authorization code path does not yet refuse a client that an admin bound to another user’s agent. This is an open policy decision.
  • A request that passed validation before the revoke commits finishes. For long handlers, call principal.session.revalidate() between steps.
  • A server that verifies the JWT itself, or caches introspection answers, sees the revoke when that cache ends or the token expires.
  • The raw token in the script is a bearer token. Revocation does not stop replay while the token is live. DPoP does.

Next steps

MCP token revocation

Freshness modes, the shared denylist and where revocation stops short.

Delegation chains

Depth limits, constraints and cascading revocation.

Explain a decision

Matched grants, near misses and constraint checks.

Delegate a scoped token

The longer walkthrough with hooks and an HTTP server.
Last modified on October 18, 2026