CSRF protection in OAuth: the state parameter and PKCE

OAuth 2.0 authorization flows involve redirects between the client application, the authorization server, and back again. Those redirects are the surface area for a class of attacks that neither the access token nor the HTTPS transport protects against: cross-site request forgery against the redirect itself. The state parameter is the original defense. PKCE is the modern extension that covers additional attack vectors. Both matter, for different reasons, and skipping either creates real exploitable vulnerabilities.

The CSRF attack against OAuth redirects

The attack works against the redirect back from the authorization server to your application. Here is the sequence that creates the vulnerability:

  1. An attacker starts the OAuth flow on your site, getting a legitimate authorization URL from the authorization server.
  2. Instead of completing the flow, the attacker stops before the redirect and captures the callback URL with the authorization code.
  3. The attacker tricks a victim (already logged into your application) into loading that callback URL — via an img tag, iframe, or link.
  4. Your application receives the callback, exchanges the authorization code for tokens, and links those tokens to the victim's account.
  5. The attacker, who controls the corresponding authorization grant, now has access tied to the victim's account.

The practical impact: an attacker can link their own social login identity to a victim's account, or force the victim's account to connect to a storage service the attacker controls — exposing all files the victim uploads.

How the state parameter prevents this

The state parameter is an opaque value your application generates before redirecting the user to the authorization server. The authorization server echoes it back in the redirect to your callback URL. Your application validates that the returned state matches what it issued.

import crypto from 'crypto';

// Step 1: Generate state before redirecting to authorization server
function initiateOAuthFlow(req, res) {
  const state = crypto.randomBytes(32).toString('base64url');

  // Store state in server-side session, NOT in the URL or a cookie
  req.session.oauthState = state;
  req.session.save();

  const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth');
  authUrl.searchParams.set('client_id', process.env.GOOGLE_CLIENT_ID);
  authUrl.searchParams.set('redirect_uri', 'https://yourapp.com/auth/callback');
  authUrl.searchParams.set('response_type', 'code');
  authUrl.searchParams.set('scope', 'openid email profile');
  authUrl.searchParams.set('state', state);  // include state

  res.redirect(authUrl.toString());
}

// Step 2: Validate state in callback
async function handleOAuthCallback(req, res) {
  const { code, state } = req.query;

  // Critical: validate state before exchanging the code
  if (!state || state !== req.session.oauthState) {
    return res.status(400).json({ error: 'invalid_state' });
  }

  // State is valid — clear it to prevent replay
  delete req.session.oauthState;
  req.session.save();

  // Now safe to exchange the authorization code
  const tokens = await exchangeCodeForTokens(code);
  // ...
}
The state value must be unpredictable and bound to the user's session. A common mistake is storing state in a cookie rather than the server-side session. A cookie-based state can be overwritten by a CSRF attack targeting the cookie, which defeats the protection entirely. Use server-side session storage.

What state does not protect against

State prevents CSRF against the redirect, but it does not prevent authorization code interception. If an authorization code is captured in transit — via a malicious redirect URI, a browser history leak, or a referrer header — the attacker can exchange it for tokens without needing to CSRF the callback. This is the authorization code interception attack, and it is the problem PKCE was designed to solve.

PKCE: Proof Key for Code Exchange

PKCE (RFC 7636) binds the authorization code to the client that requested it by introducing a cryptographic challenge-response into the flow. Even if an attacker intercepts the authorization code, they cannot exchange it without the corresponding code verifier.

import crypto from 'crypto';

// Generate PKCE pair
function generatePKCE() {
  // code_verifier: 43-128 character random string
  const codeVerifier = crypto.randomBytes(48).toString('base64url');

  // code_challenge: S256 = BASE64URL(SHA256(ASCII(code_verifier)))
  const codeChallenge = crypto
    .createHash('sha256')
    .update(codeVerifier)
    .digest('base64url');

  return { codeVerifier, codeChallenge };
}

function initiateOAuthFlowWithPKCE(req, res) {
  const state = crypto.randomBytes(32).toString('base64url');
  const { codeVerifier, codeChallenge } = generatePKCE();

  // Store both state and code_verifier in session
  req.session.oauthState = state;
  req.session.pkceVerifier = codeVerifier;
  req.session.save();

  const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth');
  authUrl.searchParams.set('client_id', process.env.GOOGLE_CLIENT_ID);
  authUrl.searchParams.set('redirect_uri', 'https://yourapp.com/auth/callback');
  authUrl.searchParams.set('response_type', 'code');
  authUrl.searchParams.set('scope', 'openid email profile');
  authUrl.searchParams.set('state', state);
  authUrl.searchParams.set('code_challenge', codeChallenge);
  authUrl.searchParams.set('code_challenge_method', 'S256');  // always S256, never plain

  res.redirect(authUrl.toString());
}

async function handleCallbackWithPKCE(req, res) {
  const { code, state } = req.query;

  if (!state || state !== req.session.oauthState) {
    return res.status(400).json({ error: 'invalid_state' });
  }

  const codeVerifier = req.session.pkceVerifier;
  delete req.session.oauthState;
  delete req.session.pkceVerifier;
  req.session.save();

  // Include code_verifier in token exchange
  const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      client_id: process.env.GOOGLE_CLIENT_ID,
      client_secret: process.env.GOOGLE_CLIENT_SECRET,
      redirect_uri: 'https://yourapp.com/auth/callback',
      code,
      code_verifier: codeVerifier,  // authorization server verifies hash matches
    }),
  });
}

PKCE for SPAs and mobile apps

PKCE was originally specified for public clients — SPAs and native mobile apps that cannot securely store a client secret. A SPA cannot keep a secret (any value bundled into JavaScript is visible to anyone who reads the source). PKCE allows public clients to prove code ownership without a secret.

For confidential clients (server-side web applications with a client secret), PKCE provides defense in depth. The client secret authenticates the application. PKCE additionally binds the specific authorization code to the specific flow instance. Using both is recommended. Several authorization servers now require PKCE even for confidential clients.

Never use code_challenge_method=plain. Plain mode sends the verifier directly as the challenge, providing no security benefit — an attacker who intercepts the authorization request gets the verifier. S256 is the only acceptable method. Most servers reject plain; use S256 unconditionally.

What happens when you skip both

Skipping state allows CSRF attacks against your OAuth callback — an attacker can link their identity to a victim's account. Skipping PKCE (for public clients especially) allows authorization code interception — any party that sees the authorization code can exchange it for tokens, since there is no code verifier to prove ownership.

In practice, the most common real-world exploit is not a sophisticated interception but a simple redirect URI misconfiguration combined with missing state validation. An attacker registers a domain that matches a wildcard redirect URI, captures the code and state in the referrer header from a subsequent navigation, and completes the exchange. State validation stops this cold because the session state will not match.

Checking authorization server support

Before relying on PKCE, verify the authorization server supports it. The OIDC discovery document at /.well-known/openid-configuration should list code_challenge_methods_supported. If the field is absent or does not include S256, the server does not support PKCE and you should raise this with the provider — or consider whether the integration is safe to ship.

# Check discovery document for PKCE support
curl -s https://accounts.google.com/.well-known/openid-configuration \
  | jq '.code_challenge_methods_supported'

# Expected output:
# [
#   "plain",
#   "S256"
# ]

Bastionary enforces PKCE on all authorization code flows by default and rejects requests with code_challenge_method=plain. The state parameter is validated server-side against the session. These are not optional settings — they are part of the baseline security guarantees the platform provides to every application.

← Back to blog Try Bastionary free →