MCP server OAuth 2.1 authorization security
The MCP 2025-03-26 specification added a first-class authorization framework based on OAuth 2.1 with mandatory PKCE. MCP authorization servers issue access tokens that MCP clients include in Authorization: Bearer headers on all subsequent requests. The authorization flow mirrors standard OAuth, but the MCP-specific context introduces new attack surfaces: weak PKCE implementations in MCP client SDKs, permissive redirect URI validation in MCP authorization servers, missing state parameter validation, and scope escalation via dynamic client registration (RFC 7591). Four SA-MCP-OAU patterns document the complete OAuth 2.1 authorization attack surface for MCP.
The MCP OAuth 2.1 flow: (1) The MCP client generates a code_verifier (cryptographic random string, 43-128 chars) and derives code_challenge = BASE64URL(SHA256(code_verifier)). (2) The client sends the user to the authorization endpoint with code_challenge and code_challenge_method=S256. (3) After user consent, the authorization server redirects to the client’s redirect_uri with an authorization code. (4) The client exchanges code + code_verifier for tokens at the token endpoint. PKCE ensures that even if the authorization code is intercepted, it cannot be exchanged without the code_verifier that only the legitimate client holds.
Community MCP server and client implementations frequently get PKCE wrong: using insufficient entropy for the code_verifier, validating redirect URIs with substring matching, or omitting state parameter validation. Each failure breaks the security guarantee that PKCE was designed to provide.
SA-MCP-OAU-001: weak PKCE code_verifier entropy allows authorization code interception
OAuth 2.1 + PKCE requires the code_verifier to be a cryptographically random string of 43-128 ASCII characters. The security of PKCE depends entirely on this randomness: if the code_verifier can be guessed or brute-forced within the token endpoint’s rate limit window, an attacker who intercepts the authorization code can exchange it for tokens. Community MCP client implementations have been observed using Math.random(), timestamp-derived strings, or short fixed-length secrets as code_verifiers.
// VULNERABLE: code_verifier generated with Math.random()
function generateCodeVerifier() {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
let verifier = '';
for (let i = 0; i < 43; i++) {
verifier += chars[Math.floor(Math.random() * chars.length)];
}
return verifier;
// Math.random() is a Xorshift PRNG seeded from process start time.
// If an attacker knows approximate process start time (observable from HTTP headers),
// they can enumerate all possible verifiers in seconds.
}
// VULNERABLE: timestamp-based — trivially predictable
function generateCodeVerifier() {
return Date.now().toString(36) + Math.random().toString(36);
// ~11 chars of timestamp + ~12 chars weak random = ~23 chars total
// Far below the 43-char minimum AND not cryptographically random
}
// VULNERABLE: short fixed-length string (< 43 chars)
const CODE_VERIFIER = 'my-mcp-app-secret'; // hardcoded — same for all users!
// SECURE: crypto.randomBytes → Base64URL encoding
import { randomBytes } from 'node:crypto';
function generateCodeVerifier() {
// 32 bytes → 43 Base64URL chars (exactly meets minimum)
return randomBytes(32).toString('base64url');
// 256 bits of entropy — not brute-forceable
}
// SECURE: Web Crypto API (browser/Deno)
async function generateCodeVerifier() {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
// DETECTION (SkillAudit static analysis):
// Find code_verifier generation patterns
const weakCodeVerifierPatterns = [
/Math\.random\(\)/,
/Date\.now\(\)/,
/code[_-]?verifier\s*=\s*['"][^'"]{1,42}['"]/, // hardcoded short string
/\.toString\(36\)/ // base36 from random/timestamp
];
// Any match → SA-MCP-OAU-001 CRITICAL
// Pass: randomBytes(n) where n >= 32, or crypto.getRandomValues with 32+ bytes
Authorization code interception is realistic on mobile networks: On 4G/5G networks, HTTP redirects can be observed by the carrier or by any on-path actor. In corporate environments, TLS inspection proxies see the redirect URI including the authorization code in the query string. PKCE was specifically designed to protect against these interceptions — but only if the code_verifier has sufficient entropy.
code_verifier generated with Math.random(), Date.now(), or <43 chars: PKCE protection nullified; intercepted authorization codes can be exchanged for tokens. Detection: non-crypto code_verifier generation → CRITICAL.SA-MCP-OAU-002: redirect URI over-matching allows authorization code theft
The OAuth 2.1 spec requires authorization servers to validate redirect URIs using exact string matching. Community MCP authorization server implementations frequently use prefix matching, domain suffix matching, or regex matching with insufficient anchoring. These permit attackers to register redirect URIs that look similar to the legitimate client URI but redirect to attacker-controlled endpoints, stealing the authorization code from the Location header.
// VULNERABLE: prefix matching — allows subdirectory open redirect
function validateRedirectUri(requestedUri, registeredUri) {
return requestedUri.startsWith(registeredUri);
// Registered: https://app.example.com/callback
// Attacker: https://app.example.com/callback.evil.com/
// startsWith check passes → attacker receives authorization code
}
// VULNERABLE: domain suffix matching — allows subdomain attack
function validateRedirectUri(requestedUri, registeredUri) {
const registered = new URL(registeredUri);
const requested = new URL(requestedUri);
return requested.hostname.endsWith(registered.hostname);
// Registered: app.example.com → endsWith('app.example.com')
// Attacker: evil-app.example.com → endsWith('app.example.com') → PASSES
}
// VULNERABLE: includes() check — allows path traversal
function validateRedirectUri(req, reg) {
return req.includes(reg);
// Registered: https://app.example.com/cb
// Attacker: https://evil.com/?x=https://app.example.com/cb&y=
// includes() passes → attacker page receives code
}
// SECURE: exact string comparison (OAuth 2.1 spec requirement)
function validateRedirectUri(requestedUri, registeredUris) {
return registeredUris.includes(requestedUri);
// Array.includes() uses strict equality → no prefix/suffix/regex tricks
}
// Also SECURE: compare after URL normalization (handles trailing slash variants)
function normalizeUri(uri) {
const u = new URL(uri);
// Remove trailing slash from path, lowercase scheme+host
u.pathname = u.pathname.replace(/\/$/, '') || '/';
return `${u.protocol}//${u.host}${u.pathname}${u.search}`;
}
// DETECTION (SkillAudit):
// Scan authorization server redirect URI validation logic
const weakRedirectValidationPatterns = [
/\.startsWith\(/, // prefix match
/\.endsWith\(/, // suffix match
/\.includes\(/, // substring match
/\.match\(|\.test\(/ // regex without full anchoring
];
// Any weak match in redirect URI validation → SA-MCP-OAU-002 CRITICAL
Open redirect is the most exploited OAuth vulnerability in the wild: The attack is simple: register a client with a redirect URI that passes the server’s validation but points to an attacker page. When the user completes authorization, the server redirects to the attacker’s URL with ?code=AUTHORIZATION_CODE in the query string. The attacker exchanges the code at the token endpoint before the legitimate client does. Exact string matching is the only defense.
startsWith(), endsWith(), includes(), or insufficiently anchored regex: attacker-controlled URIs pass validation, authorization code stolen via Location header redirect. Detection: non-exact redirect URI comparison → CRITICAL.SA-MCP-OAU-003: state parameter CSRF bypass
The OAuth 2.1 state parameter is the CSRF protection for the authorization callback. The client generates a cryptographically random state, stores it in session, sends it to the authorization endpoint, and validates it on callback before using the returned authorization code. If the state is missing, predictable, or not validated on callback, an attacker can craft a malicious callback URL containing their own authorization code and trick the client into binding the attacker’s tokens to the victim’s session.
// VULNERABLE: state not validated on callback
app.get('/oauth/callback', async (req, res) => {
const { code } = req.query;
// Missing: state parameter validation
const tokens = await exchangeCodeForTokens(code);
req.session.mcpTokens = tokens;
res.redirect('/dashboard');
// Attacker sends victim to /oauth/callback?code=ATTACKER_CODE
// Victim's session now holds attacker's tokens → session fixation
});
// VULNERABLE: predictable state (sequential, timestamp-derived)
function generateState() {
return `state_${Date.now()}`; // predictable → attacker generates next state
}
// VULNERABLE: state not stored / verified against session
app.get('/authorize', (req, res) => {
const state = crypto.randomUUID();
// Missing: req.session.oauthState = state; ← state not stored
res.redirect(buildAuthUrl({ state }));
});
app.get('/callback', (req, res) => {
const { state, code } = req.query;
// Can't validate — state was never stored. Any state value accepted.
});
// SECURE: state generated, stored in session, validated on callback
import { randomBytes } from 'node:crypto';
app.get('/authorize', (req, res) => {
const state = randomBytes(16).toString('hex'); // 128-bit random state
req.session.oauthState = state; // store in session
res.redirect(buildAuthUrl({ state }));
});
app.get('/callback', async (req, res) => {
const { state, code } = req.query;
if (!state || state !== req.session.oauthState) {
return res.status(400).json({ error: 'Invalid state parameter' });
}
delete req.session.oauthState; // invalidate used state
const tokens = await exchangeCodeForTokens(code);
req.session.mcpTokens = tokens;
res.redirect('/dashboard');
});
// DETECTION (SkillAudit):
// Check /oauth/callback or /callback route handler
// Flag: no state parameter read from req.query/req.params → CRITICAL
// Flag: state read but not compared against session value → CRITICAL
// Flag: state generation uses Date.now() or Math.random() → HIGH
const callbackHandler = findRouteHandler('/callback');
if (!callbackHandler.includes('state')) → CRITICAL // SA-MCP-OAU-003
if (!callbackHandler.includes('session.oauthState')) → HIGH // SA-MCP-OAU-003
Missing state validation enables cross-site request forgery on OAuth callbacks: An attacker who obtains a valid authorization code for their own account can trick the victim into visiting /oauth/callback?code=ATTACKER_CODE&state=anything. If the client does not validate state, it binds the attacker’s tokens to the victim’s session — the attacker then controls all MCP tool calls in the victim’s session via their bound tokens.
state parameter against session-stored value: CSRF attack binds attacker’s authorization code to victim’s session. Detection: missing state comparison in callback handler → CRITICAL.SA-MCP-OAU-004: scope escalation via dynamic client registration
MCP’s authorization spec supports dynamic client registration (RFC 7591) allowing any client to register with the authorization server. Registration includes a scope field listing requested scopes. Authorization servers that grant the exact scopes requested at registration time, without validating against a maximum allowed scope set per client type, allow a malicious MCP server to register itself with elevated scopes (e.g., admin write:all) that the server’s intended function does not require. Subsequent token requests then receive access tokens with these escalated scopes.
// VULNERABLE: authorization server grants whatever scopes client requests at registration
app.post('/register', async (req, res) => {
const { client_name, redirect_uris, scope } = req.body;
// No scope validation — grants exactly what is requested
const client = await db.createClient({
name: client_name,
redirectUris: redirect_uris,
allowedScopes: scope.split(' '), // ← directly uses requested scopes
clientId: randomUUID(),
clientSecret: randomBytes(32).toString('hex')
});
res.json({ client_id: client.clientId, client_secret: client.clientSecret });
});
// Malicious MCP server registers itself:
// POST /register { "client_name": "helpful-calendar-mcp", "scope": "read write admin delete:all" }
// Server grants: allowed_scopes = ['read', 'write', 'admin', 'delete:all']
// Later token request gets access token with all four scopes.
// SECURE: validate requested scopes against an allowlist for the client type
const PUBLIC_CLIENT_MAX_SCOPES = new Set(['mcp:read', 'mcp:tools:call', 'mcp:resources:read']);
app.post('/register', async (req, res) => {
const requestedScopes = req.body.scope?.split(' ') ?? [];
const grantedScopes = requestedScopes.filter(s => PUBLIC_CLIENT_MAX_SCOPES.has(s));
if (grantedScopes.length === 0) {
return res.status(400).json({ error: 'No valid scopes requested' });
}
const client = await db.createClient({
allowedScopes: grantedScopes, // only validated scopes granted
// ...
});
res.json({ /* ... */ scope: grantedScopes.join(' ') });
});
// DETECTION (SkillAudit):
// Check dynamic client registration endpoint (/register, /clients, /oauth/register)
// Flag: scope from request body used directly as allowedScopes without filtering → CRITICAL
// Flag: no maximum scope set defined for dynamically registered clients → HIGH
// Flag: registration endpoint accessible without authentication → HIGH
const registerRoute = findRouteHandler('/register');
if (usesRequestBodyScopeDirectly(registerRoute)) → CRITICAL // SA-MCP-OAU-004
if (!hasMaxScopeValidation(registerRoute)) → HIGH // SA-MCP-OAU-004
Dynamic client registration is the MCP-specific OAuth risk that web developers miss: Traditional OAuth deployments have pre-registered clients with statically configured scopes. MCP’s dynamic registration (needed because users install arbitrary MCP servers) means the authorization server sees registration requests from unknown clients claiming whatever scopes they want. Without a maximum scope policy, the first client to register can claim admin access — a privilege escalation that would be impossible in a traditional fixed-client OAuth deployment.
How SkillAudit detects SA-MCP-OAU patterns
| Pattern | Detection method | Severity |
|---|---|---|
| SA-MCP-OAU-001 | Static: code_verifier generation scanned for Math.random/Date.now; length check <43 chars | CRITICAL |
| SA-MCP-OAU-002 | Static: redirect URI validation logic; startsWith/endsWith/includes/unanchored regex | CRITICAL |
| SA-MCP-OAU-003 | Static: OAuth callback handler scanned for state parameter read + session comparison | CRITICAL |
| SA-MCP-OAU-004 | Static: dynamic client registration endpoint scanned for scope passthrough without filtering | HIGH |
Run a SkillAudit scan on your MCP authorization server implementation: Paste your GitHub URL at skillaudit.dev to get a graded security report covering all four SA-MCP-OAU patterns and 200+ additional MCP security checks.