Topic: mcp server OAuth PKCE security
MCP server OAuth PKCE security — code verifier requirements, S256 vs plain, and intercept attack prevention
PKCE (Proof Key for Code Exchange, RFC 7636) was designed for public clients — clients that cannot securely store a client secret, such as native apps, desktop clients, and CLI tools. MCP servers accessed via Claude Code, Cursor, or other desktop clients fall squarely in this category. Without PKCE, a malicious process on the same machine can register a custom URI scheme, intercept the authorization code redirect, and exchange the stolen code for an access token before the legitimate client completes the flow. PKCE eliminates this attack by binding the authorization code to a one-time secret the attacker does not have.
Why MCP OAuth flows are especially vulnerable to code interception
Traditional web application OAuth flows redirect to a server-controlled HTTPS URL. Only the server operator can receive the redirect. MCP server OAuth flows often redirect to localhost or to a custom URI scheme (mcp://callback, claudeext://auth), and the callback recipient is determined by which process on the local machine is listening on that port or registered that scheme.
An attacker who can run a process on the same machine — whether via a different installed MCP server, a malicious npm package installed alongside it, or a compromised dependency — can race the legitimate client to the redirect, steal the authorization code, and exchange it for a token. The authorization server does not know whether the code was received by the intended client or by the attacker, because both are running on the same machine.
PKCE solves this by requiring the token endpoint to verify a secret the client generated before the authorization request. The attacker who intercepts the code does not have that secret and cannot complete the token exchange.
How PKCE works: code verifier and code challenge
The PKCE flow adds two values to the standard authorization code flow:
- code_verifier: A cryptographically random string, 43–128 characters of URL-safe base64, generated by the client before the authorization request. Never sent to the authorization endpoint — only to the token endpoint.
- code_challenge: A transformation of the code_verifier sent in the authorization request. The authorization server stores this alongside the code. At the token endpoint, the server transforms the submitted code_verifier and compares it against the stored challenge. A mismatch = rejected exchange.
// PKCE code verifier and challenge generation (Node.js)
import { randomBytes, createHash } from 'crypto';
function generateCodeVerifier(): string {
// 32 random bytes → 43 base64url characters (minimum for RFC 7636)
return randomBytes(32)
.toString('base64url') // URL-safe, no padding
.slice(0, 43); // trim to exactly 43 chars
}
function generateCodeChallenge(verifier: string): string {
// S256: SHA-256 of the verifier, base64url-encoded
return createHash('sha256')
.update(verifier)
.digest('base64url');
}
// Build the authorization URL
const verifier = generateCodeVerifier();
const challenge = generateCodeChallenge(verifier);
const authUrl = new URL('https://auth.example.com/authorize');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', CLIENT_ID);
authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
authUrl.searchParams.set('code_challenge', challenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
authUrl.searchParams.set('scope', 'read:tools');
// Store verifier for the token exchange step
sessionStorage.set('pkce_verifier', verifier);
S256 vs plain — why plain is not acceptable
RFC 7636 defines two challenge methods:
- S256:
code_challenge = BASE64URL(SHA256(ASCII(code_verifier))). The challenge is a hash — even if an attacker sees the challenge in the authorization request, they cannot reverse it to obtain the verifier needed for the token exchange. - plain:
code_challenge = code_verifier. The challenge and the verifier are identical. An attacker who intercepts the authorization request sees the challenge and already has everything needed to complete the token exchange.plainprovides zero protection against the interception attack PKCE was designed to prevent.
// Authorization server: reject plain challenge method (Node.js / Express)
app.get('/authorize', (req, res) => {
const { code_challenge_method, code_challenge } = req.query;
// Reject plain unconditionally — offers no security over no PKCE
if (code_challenge_method === 'plain') {
return res.status(400).json({
error: 'invalid_request',
error_description: 'code_challenge_method=plain is not supported. Use S256.'
});
}
// Require PKCE for all public client flows
if (!code_challenge) {
return res.status(400).json({
error: 'invalid_request',
error_description: 'code_challenge is required for public clients'
});
}
// Validate base64url format (no padding, URL-safe chars only)
if (!/^[A-Za-z0-9\-_]{43,128}$/.test(code_challenge)) {
return res.status(400).json({
error: 'invalid_request',
error_description: 'code_challenge must be 43-128 URL-safe base64 characters'
});
}
// Store challenge with the issued authorization code
const code = generateAuthCode();
storeAuthCode(code, {
code_challenge,
code_challenge_method: 'S256',
client_id: req.query.client_id,
redirect_uri: req.query.redirect_uri,
expires_at: Date.now() + 60_000 // codes expire in 60 seconds
});
res.redirect(`${req.query.redirect_uri}?code=${code}`);
});
Token endpoint: verifier verification
The token endpoint receives the code_verifier and must verify it against the stored challenge. The comparison must be constant-time to prevent timing oracle attacks on the verification step itself:
// Token endpoint: PKCE verification (Node.js)
import { createHash, timingSafeEqual } from 'crypto';
app.post('/token', async (req, res) => {
const { code, code_verifier, grant_type, client_id, redirect_uri } = req.body;
if (grant_type !== 'authorization_code') {
return res.status(400).json({ error: 'unsupported_grant_type' });
}
const storedCode = await getAuthCode(code);
if (!storedCode || storedCode.expires_at < Date.now()) {
return res.status(400).json({ error: 'invalid_grant' });
}
// Validate verifier was provided
if (!code_verifier) {
return res.status(400).json({
error: 'invalid_request',
error_description: 'code_verifier is required'
});
}
// Recompute the challenge from the submitted verifier
const computedChallenge = createHash('sha256')
.update(code_verifier)
.digest('base64url');
// Constant-time comparison prevents timing oracle
const storedBuf = Buffer.from(storedCode.code_challenge, 'utf8');
const computedBuf = Buffer.from(computedChallenge, 'utf8');
if (storedBuf.length !== computedBuf.length ||
!timingSafeEqual(storedBuf, computedBuf)) {
// Delete the code on verification failure — prevent brute force
await deleteAuthCode(code);
return res.status(400).json({ error: 'invalid_grant' });
}
// Delete code immediately after successful verification (single use)
await deleteAuthCode(code);
// Issue access token
const accessToken = await issueAccessToken(storedCode.client_id, storedCode.scope);
res.json({ access_token: accessToken, token_type: 'Bearer' });
});
Code verifier entropy requirements
RFC 7636 specifies that the code_verifier MUST have at least 256 bits of entropy. With randomBytes(32) (32 bytes = 256 bits), a Node.js implementation meets this requirement exactly. Lower-entropy approaches fail the requirement and the protection:
// Insufficient entropy — do NOT use
const badVerifier = Math.random().toString(36); // ~52 bits — brute-forceable
const badVerifier2 = Date.now().toString(); // predictable — not random at all
const badVerifier3 = uuid(); // 122 bits — below 256-bit minimum
// Correct — 256 bits of cryptographic randomness
const goodVerifier = randomBytes(32).toString('base64url').slice(0, 43);
// Even better — use a PKCE library that handles entropy correctly
// npm install @panva/hkdf or oauth4webapi
import { generateRandomCodeVerifier, calculatePKCECodeChallenge } from 'oauth4webapi';
const verifier = generateRandomCodeVerifier(); // 32 bytes, base64url-encoded
const challenge = await calculatePKCECodeChallenge(verifier);
PKCE in MCP server OAuth implementations
When building an MCP server that acts as an OAuth client (fetching access tokens to call downstream APIs on behalf of the LLM session), the server-side code must implement the full PKCE flow. The verifier is session-scoped — each authorization session gets its own verifier generated at session start, stored securely, and deleted after the token exchange completes:
// MCP server acting as OAuth client — PKCE state management
interface PKCESession {
verifier: string;
challenge: string;
state: string; // CSRF protection — also required alongside PKCE
createdAt: number;
redirectUri: string;
}
const pkceSessionStore = new Map<string, PKCESession>();
export function startOAuthFlow(redirectUri: string): { authUrl: string; sessionId: string } {
const verifier = randomBytes(32).toString('base64url').slice(0, 43);
const challenge = createHash('sha256').update(verifier).digest('base64url');
const state = randomBytes(16).toString('hex'); // CSRF token
const sessionId = randomBytes(16).toString('hex');
pkceSessionStore.set(sessionId, {
verifier, challenge, state,
createdAt: Date.now(),
redirectUri
});
// Clean up sessions older than 5 minutes
for (const [id, session] of pkceSessionStore) {
if (Date.now() - session.createdAt > 300_000) pkceSessionStore.delete(id);
}
const authUrl = buildAuthUrl(challenge, state, redirectUri);
return { authUrl, sessionId };
}
export async function completeOAuthFlow(
sessionId: string,
code: string,
returnedState: string
): Promise<string> {
const session = pkceSessionStore.get(sessionId);
if (!session) throw new Error('Unknown or expired PKCE session');
// Verify CSRF state
if (session.state !== returnedState) throw new Error('State mismatch — possible CSRF');
// Exchange code for token with verifier
const token = await exchangeCodeForToken(code, session.verifier, session.redirectUri);
// Clean up immediately — single use
pkceSessionStore.delete(sessionId);
return token;
}
SkillAudit detection
SkillAudit's Permissions Hygiene and Security axes flag the following PKCE patterns in MCP OAuth implementations:
- oauth without pkce — OAuth authorization code requests that do not include
code_challengeandcode_challenge_methodparameters - pkce plain method — use of
code_challenge_method=plainwhich provides no protection against code interception - weak verifier entropy — code_verifier generated from
Math.random(),Date.now(), or uuid() rather thanrandomBytes(32) - no state parameter — OAuth flows missing the CSRF-preventing
stateparameter alongside PKCE - pkce session leak — PKCE verifier stored in a location accessible to other code paths (global variable, shared cache) rather than a per-session isolated store
Scan your MCP server's OAuth implementation
SkillAudit checks OAuth flows for missing PKCE, weak challenge methods, insufficient verifier entropy, and missing CSRF protection — as part of the free public repo scan.
Request a free audit →Related security topics
- MCP server OAuth2 security — full OAuth2 implementation security for MCP servers
- MCP server OAuth token rotation — rotating access tokens and refresh tokens securely
- MCP server OAuth device flow security — device authorization grant security for MCP integrations
- MCP server open redirect security — redirect_uri validation to prevent post-auth redirect attacks
- MCP server security incident response — what to do when an OAuth flow is compromised