Security Reference
MCP server OAuth token binding security
Bearer tokens issued for one MCP server can be replayed against any other server that trusts the same authorization server — unless the token is explicitly bound to the intended recipient. This is not a theoretical risk: it is the default behavior of most OAuth flows. Here is what binding is, why it matters for MCP multi-server architectures, and how to implement it.
The bearer token replay problem
Bearer tokens are vulnerable by definition: whoever holds the token can use it. Most MCP server auth implementations validate that the token is well-formed and not expired — but they do not validate that the token was issued specifically for this server. An attacker who extracts a token from one tool call can replay it against a different MCP server that shares the same authorization server.
This is especially relevant in multi-agent architectures where:
- Multiple MCP servers share one OAuth provider (Auth0, Keycloak, Okta, a custom identity service)
- LLM agents carry tokens across server calls in a session
- A compromised tool can extract the active session token from the agent's context and replay it
Why short-lived tokens alone don't solve this
Token expiry is a necessary control but not a sufficient one. A 15-minute JWT is still valid for 15 minutes after theft. In an automated agent session, 15 minutes is enough time for an attacker to extract the token and make dozens of unauthorized calls to a different MCP server before the window closes.
Prompt injection + token replay = lateral movement. A prompt injection attack that makes the agent echo its current auth token into a tool argument, combined with token replay against a higher-privileged MCP server, is a two-step lateral movement chain. Short expiry does not break this chain if the attacker is automated.
Defense 1: Audience claim validation
The JWT aud claim specifies which resource server the token was issued for. Validating it is a one-line addition that makes cross-server replay impossible:
import { jwtVerify } from 'jose';
const EXPECTED_AUDIENCE = 'https://my-mcp-server.example.com';
async function authenticate(token: string) {
const { payload } = await jwtVerify(
token,
await getPublicKey(),
{
issuer: 'https://auth.example.com',
audience: EXPECTED_AUDIENCE, // token rejected if aud != this value
}
);
return payload;
}
// At your authorization server — ensure it sets aud when issuing tokens:
// { aud: 'https://my-mcp-server.example.com', sub: userId, exp: ... }
A token issued with aud: https://server-a.example.com will fail validation at server-b.example.com even if both servers share the same signing key. This is the minimum viable token binding and costs zero additional infrastructure.
Defense 2: DPoP (Demonstrating Proof of Possession)
Audience binding makes cross-server replay impossible but doesn't prevent a token stolen from the intended server from being replayed there. DPoP (RFC 9449) binds the token to a specific cryptographic key that the client must prove possession of on each request:
// Client side — generate a key pair per session
const keyPair = await crypto.subtle.generateKey(
{ name: 'ECDSA', namedCurve: 'P-256' },
true, ['sign', 'verify']
);
// On each request, sign a DPoP proof JWT with the session key
const dpopProof = await signDPoP({
htm: 'POST', // HTTP method
htu: 'https://my-mcp-server.example.com/tool', // target URI
jti: crypto.randomUUID(), // unique per request
iat: Math.floor(Date.now() / 1000),
publicKey: keyPair.publicKey,
}, keyPair.privateKey);
// Server side — verify DPoP proof on every request
async function authenticate(authHeader: string, dpopHeader: string, request: Request) {
const { payload: tokenPayload } = await jwtVerify(authHeader, publicKey);
await verifyDPoP(dpopHeader, {
expectedHtm: request.method,
expectedHtu: request.url,
boundPublicKey: tokenPayload.cnf.jkt, // thumbprint of client's public key
maxAgeSeconds: 30, // proof valid for 30 seconds only
});
return tokenPayload;
}
With DPoP, a stolen token is useless without the corresponding private key. The session key pair lives only in memory on the legitimate client — it is not stored in a cookie, a file, or a database where it could be extracted.
Defense 3: Per-session short-lived tokens via token exchange
For architectures where DPoP is impractical, token exchange (RFC 8693) allows the MCP server to exchange a caller's long-lived token for a server-specific short-lived token that is scoped and bound to the current session:
// At session start — exchange the caller's token for a session-scoped one async function startSession(callerToken: string): Promise{ const response = await fetch('https://auth.example.com/token', { method: 'POST', body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', subject_token: callerToken, subject_token_type: 'urn:ietf:params:oauth:token-type:access_token', audience: 'https://my-mcp-server.example.com', scope: 'tool:read_file tool:list_directory', // minimum scope for this session requested_token_type: 'urn:ietf:params:oauth:token-type:access_token', }), headers: { Authorization: `Basic ${Buffer.from('client_id:client_secret').toString('base64')}` }, }); const { access_token } = await response.json(); return access_token; // short-lived, server-scoped, minimum-privilege }
SkillAudit findings for token binding
aud claim — tokens issued for any resource server accepted. Grade impact: −15 on Permissions axis. Fix: add audience validation as shown above.Audit your MCP server's token validation logic
SkillAudit checks whether JWT verification includes audience and issuer claims, and whether token lifetimes are within safe bounds. Paste your GitHub URL for a free scan.
Run free audit →Related: MCP server JWT algorithm confusion attacks — a distinct JWT vulnerability class. MCP server ambient authority security — the broader problem of over-privileged tokens in agentic contexts.