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:

// 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:

// 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:

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