Topic: mcp server oauth2 security

MCP server OAuth2 security — authorization code flow, token storage, and PKCE

MCP servers that drive OAuth2 flows on behalf of users introduce a different threat model than servers that use static credentials. The server mediates an authorization process — it can fail before token grant (flow selection, PKCE, state), at token grant (storage, refresh), or after token grant (scope, LLM access). Four attack surfaces matter: implicit flow misuse (tokens in URL fragments, no server refresh), missing PKCE (authorization code interception), insecure token storage (tokens in env vars, logs, or LLM context), and state parameter CSRF (login CSRF via forged authorization response). Each has an exact fix.

Attack 1: Implicit flow token leakage

The implicit flow (response_type=token) delivers the access token directly in the URL fragment of the redirect callback. URL fragments appear in browser history, Referer headers, server access logs for the callback endpoint, and any JavaScript running on the page. For an MCP server, the redirect callback is typically a localhost HTTP server spun up during the auth flow — but the token in the fragment is still visible in the process's HTTP access log and in any tab that navigates to the callback URL.

The authorization code flow (response_type=code) keeps the access token off the URL entirely: the code in the redirect is short-lived, single-use, and exchanged for a token in a back-channel server-to-server POST that never touches the browser.

// WRONG: implicit flow — token in URL fragment
const authUrl = new URL('https://github.com/login/oauth/authorize')
authUrl.searchParams.set('response_type', 'token')  // ← never use this
authUrl.searchParams.set('client_id', CLIENT_ID)

// CORRECT: authorization code flow
const authUrl = new URL('https://github.com/login/oauth/authorize')
// response_type defaults to 'code' — omit it or set explicitly
authUrl.searchParams.set('client_id', CLIENT_ID)
authUrl.searchParams.set('redirect_uri', 'http://localhost:8080/callback')
authUrl.searchParams.set('scope', 'public_repo')  // minimal scope
authUrl.searchParams.set('state', generateState())   // see Attack 3
authUrl.searchParams.set('code_challenge', await generateChallenge()) // see Attack 2
authUrl.searchParams.set('code_challenge_method', 'S256')

Attack 2: Missing PKCE — authorization code interception

The PKCE extension (RFC 7636) prevents an attacker who intercepts the authorization code from exchanging it for a token. Without PKCE, any process that can observe the redirect URL (a malicious app on the same device, a browser extension, a network observer for HTTP localhost callbacks) can race the legitimate client to exchange the code. With PKCE, the exchange requires knowledge of the code_verifier that was generated before the authorization request — a secret that never left the legitimate process.

import crypto from 'crypto'

function generatePKCE() {
  // code_verifier: 32 random bytes as base64url — never sent to authorization server directly
  const verifier = crypto.randomBytes(32).toString('base64url')
  // code_challenge: SHA-256 of verifier — sent in authorization request
  const challenge = crypto.createHash('sha256').update(verifier).digest('base64url')
  return { verifier, challenge }
}

const { verifier, challenge } = generatePKCE()
// Store verifier in session — needed to complete token exchange
session.pkceVerifier = verifier

// Include challenge in authorization URL
authUrl.searchParams.set('code_challenge', challenge)
authUrl.searchParams.set('code_challenge_method', 'S256')

// In callback handler — include verifier in token exchange
const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {
  method: 'POST',
  headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' },
  body: JSON.stringify({
    client_id: CLIENT_ID,
    client_secret: CLIENT_SECRET,
    code: callbackCode,
    redirect_uri: REDIRECT_URI,
    code_verifier: session.pkceVerifier,  // authorization server verifies this
  }),
})

Attack 3: Missing state parameter — login CSRF

The state parameter prevents login CSRF: an attacker initiates an authorization flow, gets the authorization code (by completing the flow with their own account), then tricks the victim into loading the callback URL with that code. If the server processes the callback without verifying that the state matches a session the user initiated, the attacker can link their credentials to the victim's session.

// Generating a cryptographically random state
function generateState(): string {
  return crypto.randomBytes(16).toString('base64url')
}

// Store state in session before redirecting
const state = generateState()
session.pendingOAuthState = state

// Include in authorization URL
authUrl.searchParams.set('state', state)

// In callback handler — verify BEFORE processing the code
app.get('/callback', (req, res) => {
  const returnedState = req.query.state as string
  if (!returnedState || returnedState !== session.pendingOAuthState) {
    // CSRF attempt or session mismatch — reject immediately
    res.status(403).send('Invalid state parameter')
    return
  }
  // Clear the pending state — each state is single-use
  delete session.pendingOAuthState
  // Now safe to exchange the code
  exchangeCodeForToken(req.query.code as string)
})

Attack 4: Insecure token storage

MCP servers face a specific token storage risk: the LLM context. If a token is included in a tool call response, a system prompt, or logged at the debug level and that log is fed back into the model, the token is exfiltrated to the model's context and potentially to any prompt-injection attacker who can read tool responses. The safe storage rule: tokens live in in-process memory or a secrets manager only. They are never serialized to disk unencrypted, never logged, and never returned in tool call responses.

// WRONG: token returned to LLM in tool response
async function getAuthStatus(): Promise {
  const token = tokenStore.get(userId)
  return `Authenticated. Token: ${token}`  // ← token now in LLM context
}

// WRONG: token logged
logger.debug('Token exchange successful', { token: accessToken })

// CORRECT: confirm auth status without exposing token
async function getAuthStatus(): Promise {
  const tokenInfo = tokenStore.getMetadata(userId)
  if (!tokenInfo) return 'Not authenticated'
  return `Authenticated as ${tokenInfo.username} (expires ${tokenInfo.expiresAt})`
  // Only metadata — never the raw token
}

// CORRECT: logging without the credential
logger.debug('Token exchange successful', {
  userId,
  scope: tokenResponse.scope,
  expiresIn: tokenResponse.expires_in,
  // token: NEVER log this
})

What SkillAudit checks

See also

Check your OAuth2 implementation for PKCE and token storage findings.

Run a free audit → How grading works →