Introduction
Tokens are the currency of authentication in MCP. A leaked access token grants an attacker full access to the user's tools and resources until it expires. Proper token management—validation, refresh, revocation, and secure storage—is the difference between a secure deployment and a breach. This lesson covers production-grade token lifecycle management.
Key Concepts
- Token Validation: Verifying that a token is authentic (signature), current (not expired), intended for this server (audience), and issued by a trusted authority (issuer).
- Token Refresh: Using a long-lived refresh token to obtain new short-lived access tokens without requiring user re-authentication.
- Token Revocation: Invalidating tokens before their natural expiry, used when users log out, credentials are compromised, or permissions change.
- Scope-Based Access: Mapping OAuth scopes (e.g.,
tools:read,resources:write) to specific MCP capabilities. - Token Binding: Associating tokens with specific client instances to prevent token theft and replay.
Real World Context
An employee leaves a company. Their MCP access must be revoked immediately, but their access token is valid for another 20 minutes. Without a revocation mechanism, the former employee (or anyone with their token) can continue invoking tools. With token revocation and short-lived tokens, access is cut within minutes.
Deep Dive
Comprehensive Token Validation
Every request to an MCP server must validate the bearer token:
typescriptimport jwt from "jsonwebtoken"; import jwksClient from "jwks-rsa"; const client = jwksClient({ jwksUri: "https://auth.example.com/.well-known/jwks.json", cache: true, cacheMaxAge: 600000 // 10 minutes }); async function getSigningKey(kid: string): Promise<string> { const key = await client.getSigningKey(kid); return key.getPublicKey(); } async function validateToken(authHeader: string): Promise<TokenPayload> { if (!authHeader?.startsWith("Bearer ")) { throw new Error("Missing or malformed Authorization header"); } const token = authHeader.slice(7); const decoded = jwt.decode(token, { complete: true }); if (!decoded || !decoded.header.kid) { throw new Error("Invalid token format"); } const publicKey = await getSigningKey(decoded.header.kid); const payload = jwt.verify(token, publicKey, { algorithms: ["RS256"], audience: "https://mcp.example.com", // RFC 8707 issuer: "https://auth.example.com", clockTolerance: 30 // 30 seconds leeway for clock skew }) as TokenPayload; // Check if token has been revoked const isRevoked = await tokenRevocationStore.isRevoked(payload.jti!); if (isRevoked) { throw new Error("Token has been revoked"); } return payload; }
The JWKS client caches public keys for performance. The clockTolerance handles minor clock skew between servers. The revocation check catches tokens that were invalidated before expiry.
Token Refresh Flow
Implement automatic token refresh on the server side for seamless user experience:
typescriptinterface TokenStore { accessToken: string; refreshToken: string; expiresAt: number; } async function getValidToken(store: TokenStore): Promise<string> { // Refresh 60 seconds before expiry to avoid race conditions if (Date.now() >= store.expiresAt - 60_000) { const response = await fetch("https://auth.example.com/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: store.refreshToken, client_id: process.env.CLIENT_ID! }) }); if (!response.ok) { throw new Error("Token refresh failed - user must re-authenticate"); } const data = await response.json(); store.accessToken = data.access_token; store.refreshToken = data.refresh_token ?? store.refreshToken; store.expiresAt = Date.now() + data.expires_in * 1000; } return store.accessToken; }
Refreshing 60 seconds before expiry prevents tool invocations from failing due to token expiration mid-request.
Token Revocation Endpoint
Implement RFC 7009 token revocation:
typescriptimport { Router } from "express"; const revocationRouter = Router(); revocationRouter.post("/revoke", async (req, res) => { const { token, token_type_hint } = req.body; if (!token) { return res.status(400).json({ error: "invalid_request" }); } try { if (token_type_hint === "refresh_token" || !token_type_hint) { await revokeRefreshToken(token); } if (token_type_hint === "access_token" || !token_type_hint) { const decoded = jwt.decode(token) as jwt.JwtPayload; if (decoded?.jti) { await tokenRevocationStore.revoke(decoded.jti, decoded.exp!); } } } catch { // RFC 7009: always return 200 to prevent token existence probing } res.sendStatus(200); });
Per RFC 7009, the revocation endpoint always returns 200 regardless of whether the token was valid. This prevents attackers from probing which tokens exist.
Scope-to-Capability Mapping
Map OAuth scopes to specific MCP capabilities:
typescriptconst SCOPE_CAPABILITIES: Record<string, string[]> = { "tools:read": ["search", "get", "list"], "tools:write": ["create", "update"], "tools:admin": ["delete", "configure"], "resources:read": ["read_file", "list_files"], "resources:write": ["write_file", "delete_file"] }; function hasCapability(scopes: string[], toolName: string): boolean { for (const scope of scopes) { const capabilities = SCOPE_CAPABILITIES[scope]; if (capabilities?.includes(toolName)) return true; } return false; }
This mapping ensures that OAuth scopes translate to fine-grained tool permissions.
Common Pitfalls
- Not validating audience: Without audience validation, a token issued for Service A can be replayed against Service B. Always check the
audclaim. - Infinite refresh token lifetime: Refresh tokens should have maximum lifetimes and be rotated on use. An immortal refresh token is equivalent to a permanent password.
- Storing tokens in browser localStorage: LocalStorage is accessible to any JavaScript on the page. Use httpOnly cookies or secure in-memory storage for web-based MCP clients.
Best Practices
- Rotate refresh tokens: Issue a new refresh token with each refresh request. This limits the window if a refresh token is compromised.
- Use short access token lifetimes: 15-30 minutes balances security with user experience. Shorter lifetimes reduce the impact of token leakage.
- Implement revocation checking: For high-security deployments, check a revocation store on every request. For lower-risk scenarios, short token lifetimes may suffice.
Summary
- Token validation must check signature, expiry, audience (RFC 8707), issuer, and revocation status
- Proactive token refresh (before expiry) prevents tool invocation failures
- Token revocation endpoints must always return 200 per RFC 7009 to prevent token probing
- OAuth scopes map to MCP tool capabilities for fine-grained access control
- Refresh tokens must be rotated and have maximum lifetimes
Code Examples
async function validateToken(authHeader: string): Promise<TokenPayload> {
const token = authHeader.slice(7); // Remove 'Bearer '
const decoded = jwt.decode(token, { complete: true });
const publicKey = await getSigningKey(decoded!.header.kid!);
const payload = jwt.verify(token, publicKey, {
algorithms: ["RS256"],
audience: "https://mcp.example.com",
issuer: "https://auth.example.com",
clockTolerance: 30
}) as TokenPayload;
if (await revocationStore.isRevoked(payload.jti!)) {
throw new Error("Token revoked");
}
return payload;
}