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:
- An attacker starts the OAuth flow on your site, getting a legitimate authorization URL from the authorization server.
- Instead of completing the flow, the attacker stops before the redirect and captures the callback URL with the authorization code.
- The attacker tricks a victim (already logged into your application) into loading that callback URL — via an img tag, iframe, or link.
- Your application receives the callback, exchanges the authorization code for tokens, and links those tokens to the victim's account.
- 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);
// ...
}
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.
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.