Web Security

OAuth 2.0 Security Vulnerabilities: Common Misconfigurations and Fixes

A deep dive into OAuth 2.0 security flaws: state parameter CSRF, open redirect URI vulnerabilities, token leakage, PKCE enforcement, and implicit flow risks.

March 9, 20267 min readShipSafer Team

OAuth 2.0 Security Vulnerabilities: Common Misconfigurations and Fixes

OAuth 2.0 is the dominant authorization framework for web and mobile applications. It enables users to grant third-party applications limited access to their resources without sharing credentials. Despite being well-specified, OAuth implementations are frequently misconfigured in ways that allow account takeover, authorization code interception, and token theft. This guide covers the most impactful OAuth security vulnerabilities and how to fix them.

The State Parameter and CSRF

The state parameter is OAuth 2.0's CSRF protection mechanism. When your application redirects a user to the authorization server, it includes a random state value. After the user authorizes and is redirected back, your application verifies that the returned state matches what it originally sent.

Without state validation, an attacker can execute a CSRF attack against the OAuth flow:

  1. Attacker initiates the OAuth flow with a legitimate provider and intercepts the authorization code.
  2. Attacker creates a link that, when visited by the victim, submits the attacker's authorization code to the victim's application.
  3. If the application doesn't validate state, it accepts the code and links the attacker's external account to the victim's account.
  4. The attacker can now log into the victim's account using their own credentials.

Fix: Always generate a cryptographically random state value, store it in the user's session, and verify it on callback.

import crypto from 'crypto';
import { cookies } from 'next/headers';

// When initiating the OAuth flow
export function buildAuthorizationUrl(provider: OAuthProvider): string {
  const state = crypto.randomBytes(32).toString('hex');

  // Store state in a secure, httpOnly session cookie
  cookies().set('oauth_state', state, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 600, // 10 minutes
  });

  const params = new URLSearchParams({
    response_type: 'code',
    client_id: provider.clientId,
    redirect_uri: provider.redirectUri,
    scope: provider.scope,
    state,
  });

  return `${provider.authorizationEndpoint}?${params.toString()}`;
}

// On callback
export async function handleCallback(code: string, returnedState: string) {
  const storedState = cookies().get('oauth_state')?.value;

  if (!storedState || storedState !== returnedState) {
    throw new Error('State mismatch — possible CSRF attack');
  }

  // State is valid, delete it to prevent replay
  cookies().delete('oauth_state');

  // Exchange code for tokens...
}

Open Redirect URI Vulnerabilities

The redirect_uri parameter tells the authorization server where to send the user after authorization. If the authorization server validates redirect URIs loosely, an attacker can manipulate the URI to capture authorization codes.

Common misvalidation patterns:

  • Prefix matching only: https://app.example.com allows https://app.example.com.evil.com
  • Substring matching: example.com in the URI allows evil-example.com
  • Path traversal: https://app.example.com/callback allows https://app.example.com/callback/../../../attacker-controlled-page
  • Fragment confusion: Some servers accept https://app.example.com/callback# as equivalent to the registered URI

Fix for authorization servers: Require exact match registration of allowed redirect URIs. Never use prefix or substring matching.

Fix for clients: Register the most specific URI possible. Avoid wildcards in redirect URI registration. If your OAuth provider allows it, register per-environment URIs (/callback/google, /callback/github) rather than a generic /callback.

Testing your configuration:

# These should all be rejected if your base URI is https://app.example.com/callback
https://app.example.com.evil.com/callback
https://app.example.com/callback/../admin
https://app.example.com/callback?extra=param
https://attacker.com/callback

Authorization Code Interception via Referrer and Logs

Authorization codes appear in the URL when the user is redirected back to your application (/callback?code=ABC123&state=...). This means the authorization code can leak through:

  • HTTP Referer headers when the page loads resources from third-party domains
  • Browser history
  • Server access logs
  • Proxy logs
  • Analytics scripts that capture URL parameters

Fix: Redirect immediately after processing the authorization code, removing it from the URL. Use the Referrer-Policy: no-referrer or origin header on your callback page:

// app/auth/callback/route.ts
export async function GET(request: Request) {
  const url = new URL(request.url);
  const code = url.searchParams.get('code');
  const state = url.searchParams.get('state');

  // Validate state, exchange code for tokens
  const tokens = await exchangeCodeForTokens(code, state);

  // Redirect to clean URL immediately — code is no longer in the URL
  return Response.redirect('/dashboard', 302);
}

Authorization codes should be short-lived (maximum 10 minutes, ideally 60 seconds) and single-use. Your authorization server should invalidate a code after it has been used once.

PKCE: Proof Key for Code Exchange

PKCE (RFC 7636) was designed to protect public clients (mobile apps, SPAs) where a client secret cannot be kept confidential. It has since been adopted as a best practice for all OAuth flows, including confidential clients.

PKCE works by binding the authorization request to the token request:

  1. Client generates a random code_verifier (43-128 character random string).
  2. Client computes code_challenge = BASE64URL(SHA256(code_verifier)).
  3. Authorization request includes code_challenge and code_challenge_method=S256.
  4. Token request includes the original code_verifier.
  5. Authorization server verifies that SHA256(code_verifier) == code_challenge before issuing tokens.

An attacker who intercepts the authorization code cannot exchange it for tokens without the code_verifier, which never leaves the legitimate client.

import crypto from 'crypto';

function generatePKCE(): { codeVerifier: string; codeChallenge: string } {
  const codeVerifier = crypto.randomBytes(64)
    .toString('base64url')
    .substring(0, 128);

  const codeChallenge = crypto
    .createHash('sha256')
    .update(codeVerifier)
    .digest('base64url');

  return { codeVerifier, codeChallenge };
}

// During authorization
const { codeVerifier, codeChallenge } = generatePKCE();
sessionStorage.setItem('pkce_verifier', codeVerifier); // SPA: sessionStorage; server: session

const authParams = new URLSearchParams({
  response_type: 'code',
  client_id: CLIENT_ID,
  redirect_uri: REDIRECT_URI,
  code_challenge: codeChallenge,
  code_challenge_method: 'S256',
  state: generateState(),
});

// During token exchange
const tokenParams = new URLSearchParams({
  grant_type: 'authorization_code',
  code: authorizationCode,
  redirect_uri: REDIRECT_URI,
  code_verifier: sessionStorage.getItem('pkce_verifier') ?? '',
});

PKCE with S256 should be mandatory for all new OAuth implementations. Use plain challenge method only if your environment cannot support SHA-256, which is rare.

The Implicit Flow: Why You Should Stop Using It

The OAuth 2.0 implicit flow (response_type=token) was designed for browser-based applications that couldn't keep a client secret. Instead of returning an authorization code, it returns the access token directly in the URL fragment:

https://app.example.com/callback#access_token=eyJ...&token_type=bearer

This design has fundamental security problems:

  • Access tokens in URL fragments can be captured by malicious scripts on the page, browser extensions, and server logs (if fragments are logged).
  • There is no client authentication — any page that can intercept the redirect can use the token.
  • Tokens in fragments persist in browser history.
  • PKCE cannot be applied to the implicit flow.

The OAuth Security BCP (RFC 9700, formerly draft-ietf-oauth-security-topics) explicitly recommends against the implicit flow. All major authorization servers now support the authorization code flow with PKCE for SPAs, which provides equivalent functionality without these risks.

Migration path:

// Old: implicit flow
const authUrl = `${AUTH_ENDPOINT}?response_type=token&client_id=${CLIENT_ID}`;

// New: authorization code + PKCE
const { codeVerifier, codeChallenge } = generatePKCE();
const authUrl = `${AUTH_ENDPOINT}?` + new URLSearchParams({
  response_type: 'code',
  client_id: CLIENT_ID,
  redirect_uri: REDIRECT_URI,
  code_challenge: codeChallenge,
  code_challenge_method: 'S256',
}).toString();

Token Storage and Leakage

Where you store tokens matters as much as how you obtain them.

For SPAs: Do not store access tokens in localStorage or sessionStorage — they are accessible to any JavaScript on the page, including third-party scripts and XSS payloads. Use in-memory storage (a module-level variable) for access tokens, and store refresh tokens in httpOnly cookies. An alternative is to route all OAuth token exchange through a backend-for-frontend (BFF) pattern, keeping tokens server-side entirely.

For server-side applications: Store tokens encrypted in the session, not as plaintext. Set session cookies with httpOnly, Secure, and SameSite=Lax flags.

Token lifetimes: Keep access token lifetimes short (15 minutes to 1 hour). Use refresh tokens for long-lived access, and rotate refresh tokens on each use (refresh_token_rotation). If a refresh token is used twice, it indicates theft — revoke the entire token family.

Scope Validation

Always request the minimum scope needed. When your application receives a token, validate that the granted scope matches what you requested before using the token. Overly broad scopes amplify the impact of token theft.

function validateTokenScope(token: OAuthToken, requiredScopes: string[]): void {
  const grantedScopes = token.scope.split(' ');
  for (const required of requiredScopes) {
    if (!grantedScopes.includes(required)) {
      throw new Error(`Required scope '${required}' not granted`);
    }
  }
}

OAuth security is an area where the specification provides the right primitives — state, PKCE, short-lived codes — and vulnerabilities consistently arise from implementations that skip these steps. Treat each OAuth flow parameter as a security control, not optional boilerplate.

Check Your Security Score — Free

See exactly how your domain scores on DMARC, TLS, HTTP headers, and 25+ other automated security checks in under 60 seconds.