OAuth 2.0 Misconfigurations in the Wild

A survey of the most common OAuth implementation mistakes across 200+ production applications, with proof-of-concept demonstrations and remediation patterns.

Over the past two years, I’ve assessed the OAuth 2.0 implementations of over 200 production web applications through penetration tests and bug bounty programs. The same misconfigurations appear with alarming regularity.

This is not a tutorial on OAuth. This is a field guide to what breaks in practice.

The Top Five

In order of frequency:

  1. Missing or insufficient state parameter validation (68% of apps)
  2. Open redirect via redirect_uri manipulation (41%)
  3. Token leakage through referrer headers (35%)
  4. Insufficient scope validation on the resource server (29%)
  5. PKCE not implemented for public clients (52% of SPAs)

Let’s walk through each one.

1. State Parameter Bypass

The state parameter in OAuth prevents CSRF attacks during the authorization flow. Without it, an attacker can initiate an OAuth flow with their authorization code and trick the victim into completing it — linking the attacker’s identity provider account to the victim’s application account.

In practice, I find three variants:

No state parameter at all:

GET /oauth/authorize?
  client_id=app123&
  redirect_uri=https://app.example.com/callback&
  response_type=code&
  scope=openid+profile

State present but not validated on callback:

// Vulnerable: state is sent but never checked
app.get('/callback', async (req, res) => {
  const { code } = req.query;
  // Missing: verify req.query.state matches session state
  const token = await exchangeCodeForToken(code);
  // ...
});

State validated but predictable:

// Weak: state is just the user ID, easily guessable
const state = Buffer.from(userId).toString('base64');

The fix is to generate a cryptographically random state, bind it to the user’s session, and verify it on callback:

import crypto from 'crypto';

// Before redirect to IdP
const state = crypto.randomBytes(32).toString('hex');
req.session.oauthState = state;

// On callback
app.get('/callback', (req, res) => {
  if (req.query.state !== req.session.oauthState) {
    return res.status(403).send('State mismatch');
  }
  delete req.session.oauthState;
  // ... proceed with code exchange
});

2. Open Redirect via redirect_uri

The OAuth spec requires that the authorization server validate the redirect_uri against a pre-registered list. In practice, implementations vary from strict exact-match (correct) to prefix-match (dangerous) to no validation at all (critical).

Prefix matching vulnerability:

If the server allows any URL that starts with the registered redirect URI, the attacker can append paths:

Registered:  https://app.example.com/callback
Exploited:   https://app.example.com/callback/../attacker-page
Exploited:   https://app.example.com/callback?next=https://evil.com

Subdomain matching vulnerability:

If the server allows any subdomain of the registered domain:

Registered:  https://app.example.com/callback
Exploited:   https://evil.app.example.com/callback

This requires the attacker to control a subdomain — which is more common than you’d think, given dangling DNS records and cloud service takeovers.

3. Token Leakage Through Referrer

When an OAuth callback page includes external resources (analytics scripts, CDN assets, social media widgets), the authorization code may leak through the Referer header:

1. User redirected to: https://app.com/callback?code=SECRET_CODE
2. Page loads <script src="https://analytics.evil.com/track.js">
3. Browser sends: Referer: https://app.com/callback?code=SECRET_CODE
4. analytics.evil.com now has the authorization code

The mitigation is straightforward:

<!-- On the callback page -->
<meta name="referrer" content="no-referrer">

Or better: immediately exchange the code for a token on the server side and redirect the user to a clean URL before rendering any page with external resources.

4. Insufficient Scope Validation

The authorization server issues tokens with specific scopes. The resource server must enforce these scopes on every request. I regularly find APIs where scope validation is either missing or only checked at the gateway level:

// Vulnerable: no scope check on the endpoint
app.get('/api/users/:id/payment-methods', authenticate, (req, res) => {
  // Token might only have 'read:profile' scope
  // but we're returning payment data
  return res.json(user.paymentMethods);
});

// Fixed: explicit scope requirement
app.get('/api/users/:id/payment-methods',
  authenticate,
  requireScope('read:billing'),  // Explicit scope check
  (req, res) => {
    return res.json(user.paymentMethods);
  }
);

5. Missing PKCE for Public Clients

Single-page applications and mobile apps are “public clients” — they can’t securely store a client secret. PKCE (Proof Key for Code Exchange) prevents authorization code interception attacks for these clients.

Without PKCE, an attacker who intercepts the authorization code (through a malicious browser extension, a compromised redirect, or URI scheme hijacking on mobile) can exchange it for a token. PKCE prevents this by binding the code to the original client request.

Despite being required by OAuth 2.1 and recommended by the IETF since 2019, over half of the SPAs I assessed don’t implement it.

// PKCE implementation for SPAs
import crypto from 'crypto';

function generatePKCE() {
  const verifier = crypto.randomBytes(32)
    .toString('base64url');

  const challenge = crypto
    .createHash('sha256')
    .update(verifier)
    .digest('base64url');

  return { verifier, challenge };
}

// Authorization request includes the challenge
const { verifier, challenge } = generatePKCE();
sessionStorage.setItem('pkce_verifier', verifier);

const authUrl = new URL('https://idp.example.com/authorize');
authUrl.searchParams.set('code_challenge', challenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
// ... other params

// Token exchange includes the verifier
const tokenResponse = await fetch('https://idp.example.com/token', {
  method: 'POST',
  body: new URLSearchParams({
    grant_type: 'authorization_code',
    code: authorizationCode,
    code_verifier: verifier,  // Server verifies this matches the challenge
  }),
});

The Pattern

These aren’t obscure edge cases. They’re the common failure modes. The OAuth specification is complex, and most implementations are done by developers who understandably focus on “make login work” rather than “make login secure.”

If you’re building OAuth integration, use a well-maintained library. If you’re assessing one, check these five things first — they’ll cover the majority of real-world issues.