OAuth 2.0 Security Mistakes That'll Make You Cringe š¬š
OAuth 2.0 Security Mistakes That'll Make You Cringe š¬š
Hot take: OAuth 2.0 is one of the most widely used ā and most widely misunderstood ā security protocols on the internet.
Every major platform runs on it. Google Sign-In? OAuth. "Login with GitHub"? OAuth. That Slack integration your team uses to route alerts? Yep, OAuth.
And yet, developers make the same preventable mistakes over and over again. Mistakes that turn your nice, secure "Sign in with Google" button into a front door with no lock. šŖšØ
I've reviewed a lot of codebases. I've seen OAuth implementations that made me want to close my laptop and become a shepherd. Let me save you from that fate.
The Quick OAuth Refresher (For The People In The Back) šļø
Before we roast bad implementations, here's what a correct Authorization Code flow looks like:
User clicks "Login with Google"
ā
Your app redirects to Google's auth server
ā
User authenticates + approves scopes
ā
Google redirects BACK to your app with a short-lived `code`
ā
Your BACKEND exchanges `code` for an access token (never the browser!)
ā
You use the token to call Google's API
Sounds simple. Turns out there are about 11 places to screw this up. Let's hit the greatest hits. šµ
Mistake #1: Skipping the state Parameter (CSRF Bait) š£
What most tutorials show:
// "Login with GitHub" ā the naive version
app.get('/auth/github', (req, res) => {
const authUrl = `https://github.com/login/oauth/authorize
?client_id=${CLIENT_ID}
&redirect_uri=${REDIRECT_URI}
&scope=read:user`;
res.redirect(authUrl);
});
The problem: No state parameter. This opens you to a CSRF attack where an attacker tricks your user into completing an OAuth flow that was initiated by the attacker. The result? The attacker's GitHub account gets linked to your user's account. Your user is now locked out. Attacker is in. š©
The fix ā generate and verify a state value:
const crypto = require('crypto');
app.get('/auth/github', (req, res) => {
// Generate a random, unguessable state value
const state = crypto.randomBytes(16).toString('hex');
// Store it in the session BEFORE redirecting
req.session.oauthState = state;
const authUrl = new URL('https://github.com/login/oauth/authorize');
authUrl.searchParams.set('client_id', CLIENT_ID);
authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
authUrl.searchParams.set('scope', 'read:user');
authUrl.searchParams.set('state', state); // ā
CSRF protection
res.redirect(authUrl.toString());
});
app.get('/auth/callback', (req, res) => {
const { code, state } = req.query;
// ā
Verify state matches what we stored
if (!state || state !== req.session.oauthState) {
return res.status(403).send('Invalid state parameter. Possible CSRF attack!');
}
// Clear state from session after use
delete req.session.oauthState;
// Now safely exchange the code for a token...
});
Rule: If your OAuth flow doesn't have state, you don't have OAuth ā you have a CSRF vulnerability with a login button painted on top. šØ
Mistake #2: Doing the Token Exchange in the Browser š±
The crime scene:
// This is in your FRONTEND JavaScript. Dear reader, I am so sorry.
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
// Exchanging code for token IN THE BROWSER š
fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
body: JSON.stringify({
client_id: 'abc123',
client_secret: 'my_secret_is_now_public', // š¬
code: code
})
})
The problem: Your client_secret is now visible to anyone who opens DevTools. The entire point of the client secret is that only your server knows it. Once it's in browser JavaScript, it's public. Game over. An attacker can now impersonate your application entirely. š
The fix: The code exchange happens server-side only. Always.
// ā
BACKEND route ā the client_secret never leaves the server
app.get('/auth/callback', async (req, res) => {
const { code, state } = req.query;
// ... validate state first (see Mistake #1) ...
const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: process.env.GITHUB_CLIENT_ID,
client_secret: process.env.GITHUB_CLIENT_SECRET, // ā
Server-side env var
code: code
})
});
const { access_token } = await tokenResponse.json();
// Store token server-side, give user a session cookie ā never expose raw token
req.session.accessToken = access_token;
res.redirect('/dashboard');
});
The golden rule: client_secret lives in environment variables on your server. Full stop. No exceptions. Not even "just for testing." š
Mistake #3: Open Redirect in the redirect_uri š
The sneaky attack:
https://yourapp.com/auth?redirect_uri=https://evil.com/steal-my-token
If your authorization server doesn't strictly validate the redirect_uri against a registered allowlist, an attacker can intercept the authorization code by pointing the redirect at their own server.
How it should look in your OAuth provider settings:
# Exact, registered redirect URIs (not wildcards!)
Allowed redirect URIs:
ā
https://yourapp.com/auth/callback
ā https://yourapp.com/*
ā https://*.yourapp.com/callback
ā http://yourapp.com/auth/callback (HTTP, not HTTPS)
And in your code ā validate it server-side too:
const ALLOWED_REDIRECT_URIS = new Set([
'https://yourapp.com/auth/callback',
'https://yourapp.com/auth/callback/mobile' // If you have multiple clients
]);
app.get('/auth/start', (req, res) => {
const redirect = req.query.redirect_uri || 'https://yourapp.com/auth/callback';
if (!ALLOWED_REDIRECT_URIS.has(redirect)) {
return res.status(400).send('Invalid redirect URI');
}
// ... proceed with auth flow
});
Remember: The spec requires exact URI matching. Any flexibility you add is a vulnerability you're adding. šÆ
Mistake #4: Using the Implicit Flow (It's Deprecated For Good Reason) šŖ¦
You might see old tutorials using response_type=token. This is the Implicit Flow, and it was officially deprecated in RFC 9700 because it returns the access token directly in the URL fragment.
The dangers:
- Access token leaks via browser history
- Access token leaks via HTTP
Refererheaders - No way to verify the token wasn't tampered with
- Zero protection against token injection attacks
What to use instead:
# Instead of:
response_type=token ā (Implicit ā deprecated)
# Use:
response_type=code ā
(Authorization Code)
# And for public clients (mobile, SPA):
response_type=code + PKCE ā
ā
(Authorization Code with PKCE)
For SPAs and mobile apps ā use PKCE:
// PKCE (Proof Key for Code Exchange) ā the modern way for public clients
function generatePKCE() {
// 1. Generate a random code_verifier
const verifier = crypto.randomBytes(32).toString('base64url');
// 2. Hash it to create code_challenge
const challenge = crypto.createHash('sha256')
.update(verifier)
.digest('base64url');
return { verifier, challenge };
}
const { verifier, challenge } = generatePKCE();
sessionStorage.setItem('pkce_verifier', verifier); // Store verifier
const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('code_challenge', challenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
// ... other params
// On callback, include verifier in token exchange:
// code_verifier = sessionStorage.getItem('pkce_verifier')
Why PKCE works: Even if an attacker intercepts the code, they can't exchange it without the code_verifier ā which never left the client. š
Mistake #5: Storing Tokens in localStorage šļøā
This one is so common it's practically a tradition.
// The localStorage Hall of Shame
localStorage.setItem('access_token', token); // ā XSS will steal this
localStorage.setItem('refresh_token', token); // ā Even worse!
The problem: Any JavaScript on your page ā including third-party scripts, browser extensions, and XSS payloads ā can read localStorage. Tokens stored there are one document.cookie alternative-style XSS away from being stolen.
Better options:
| Storage | XSS Safe | CSRF Safe | Use For |
|---|---|---|---|
localStorage |
ā No | ā Yes | Nothing sensitive |
sessionStorage |
ā No | ā Yes | Short-lived, low-risk |
HttpOnly Cookie |
ā Yes | ā No (use SameSite) | Auth tokens ā |
HttpOnly + SameSite=Strict |
ā Yes | ā Yes | Best option |
// ā
Store token in HttpOnly cookie (server-side)
res.cookie('access_token', token, {
httpOnly: true, // JS cannot read this
secure: true, // HTTPS only
sameSite: 'strict', // No cross-site sending
maxAge: 3600000 // 1 hour
});
The tradeoff: HttpOnly cookies require CSRF protection (use SameSite + a CSRF token). But that's a much better problem to have than "attacker has all my users' tokens." š”ļø
The OAuth Security Checklist You Actually Need ā
Before shipping that "Login with GitHub" button:
ā” state parameter generated (random, cryptographically secure)
ā” state verified on callback (reject if missing or mismatched)
ā” redirect_uri validated against explicit allowlist
ā” code exchange happens SERVER-SIDE only
ā” client_secret is in env vars, never in frontend code
ā” Using Authorization Code flow (not Implicit)
ā” PKCE enabled for SPAs and mobile apps
ā” Access tokens NOT stored in localStorage
ā” Tokens in HttpOnly, Secure, SameSite cookies
ā” Scopes are minimal (only request what you need!)
ā” Token expiration is enforced (short-lived access tokens)
ā” Refresh tokens are rotated on use
If you check all of these, you're doing better than 80% of OAuth implementations in the wild. That's not me being hyperbolic ā that's the result of actual audits. š¬
The Bottom Line š”
OAuth 2.0 is not magic. It's a protocol with sharp edges, and the spec is long for a reason. The good news is the attack surface is well-understood and the fixes are not complicated ā they just require you to actually read the spec (or at least this blog post).
The three things that'll save you 90% of the pain:
- Always use and verify the
stateparameter - Never touch
client_secretin the browser - Use Authorization Code + PKCE and HttpOnly cookies
OAuth done right is genuinely great. OAuth done wrong is a phishing-as-a-service platform you accidentally built yourself. š
Found an OAuth horror story in your own codebase? Come commiserate on LinkedIn ā I've heard them all and I will judge you zero percent.
Want to see these patterns in real code? Check out GitHub for working OAuth examples with PKCE and proper state handling.
Now go lock down those OAuth flows ā your users' accounts are counting on you! ššāØ
P.S. If you're using a library like Passport.js, NextAuth, or Laravel Socialite ā read the docs for the state parameter. Most libraries support it but don't enable it by default. Classic. š