OAuth 2.0 Security Pitfalls: When 'Login with Google' Goes Wrong ๐
OAuth 2.0 Security Pitfalls: When 'Login with Google' Goes Wrong ๐
Ever wondered why every website wants you to "Sign in with Google" or "Continue with GitHub"? It's OAuth 2.0, baby! But here's the thing nobody tells you: implementing it wrong is hilariously easy. ๐
In my experience building production systems for e-commerce platforms, I've seen OAuth implementations that made me want to cry. In security communities, we joke that OAuth stands for "Oh, Auth is broken" because of how many developers mess it up.
Let me save you from becoming another cautionary tale!
What's OAuth 2.0 Anyway? ๐ค
Think of OAuth like valet parking for your digital identity:
- You (user) want to use a restaurant (third-party app)
- You give the valet (OAuth provider like Google) your car keys
- The valet gives the restaurant a temporary parking ticket (access token)
- The restaurant can park your car, but can't steal it and drive to Vegas
The magic: The restaurant never sees your actual keys (password)!
The Classic Mistakes (And How to Fix Them) ๐ฅ
1. Not Validating the Redirect URI ๐ฏ
The Attack: Authorization code interception
This is the #1 OAuth vulnerability I see in the wild. As someone passionate about security, this one drives me NUTS.
The Bad Way:
// DON'T DO THIS - accepts any redirect URI!
app.get('/oauth/callback', (req, res) => {
const code = req.query.code;
const redirectUri = req.query.redirect_uri; // ๐จ DANGER!
// Exchange code for token...
exchangeCodeForToken(code, redirectUri);
});
What a hacker does:
https://yoursite.com/oauth/callback?
code=AUTH_CODE&
redirect_uri=https://evil-hacker.com/steal
Result: Your auth code gets sent to the hacker's server. Game over! ๐
The Safe Way:
// Whitelist exact redirect URIs
const ALLOWED_REDIRECTS = [
'https://yoursite.com/auth/callback',
'https://yoursite.com/app/callback'
];
app.get('/oauth/callback', (req, res) => {
const redirectUri = req.query.redirect_uri;
// Validate before doing ANYTHING
if (!ALLOWED_REDIRECTS.includes(redirectUri)) {
return res.status(400).json({ error: 'Invalid redirect URI' });
}
// Now safe to proceed
const code = req.query.code;
exchangeCodeForToken(code, redirectUri);
});
Pro Tip: Use exact string matching, not pattern matching! A hacker can register yoursite.com.evil.com and bypass regex checks.
2. Skipping State Parameter Validation ๐ฒ
The Attack: CSRF on your OAuth flow
The state parameter is like a security seal on a pill bottle - if it's missing or wrong, something's fishy!
The Bad Way:
// Missing state = CSRF vulnerability
app.get('/auth/google', (req, res) => {
const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?
client_id=${CLIENT_ID}&
redirect_uri=${REDIRECT_URI}&
response_type=code`; // ๐จ No state!
res.redirect(authUrl);
});
How hackers exploit this:
- Hacker starts OAuth flow, gets their own
code - Tricks victim into clicking malicious link with hacker's
code - Victim's account gets linked to hacker's OAuth account
- Hacker can now access victim's data!
The Safe Way:
const crypto = require('crypto');
// Step 1: Generate and store state
app.get('/auth/google', (req, res) => {
// Generate random state
const state = crypto.randomBytes(32).toString('hex');
// Store in session or signed cookie
req.session.oauthState = state;
const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?
client_id=${CLIENT_ID}&
redirect_uri=${REDIRECT_URI}&
response_type=code&
state=${state}`; // โ
Include state
res.redirect(authUrl);
});
// Step 2: Validate state in callback
app.get('/oauth/callback', (req, res) => {
const { code, state } = req.query;
const savedState = req.session.oauthState;
// Verify state matches
if (!state || state !== savedState) {
return res.status(400).json({ error: 'Invalid state parameter' });
}
// Clear state after use
delete req.session.oauthState;
// Now safe to exchange code
exchangeCodeForToken(code);
});
Real Talk: I've seen production systems skip the state parameter because "it's optional." Sure, seatbelts are optional too... until you crash! ๐๐ฅ
3. Storing Tokens Insecurely ๐๏ธ
The Nightmare: Access tokens in localStorage
This is controversial, but hear me out: DON'T store OAuth tokens in localStorage!
Why it's bad:
// ๐จ Any JavaScript can read this!
localStorage.setItem('access_token', token);
// Including malicious scripts from:
// - XSS vulnerabilities
// - Compromised CDN
// - Browser extensions
The Better Way:
// Use httpOnly cookies (JavaScript can't access)
app.get('/oauth/callback', async (req, res) => {
const { code } = req.query;
const tokens = await exchangeCodeForToken(code);
// Store in httpOnly cookie
res.cookie('access_token', tokens.access_token, {
httpOnly: true, // No JavaScript access
secure: true, // HTTPS only
sameSite: 'strict', // CSRF protection
maxAge: 3600000 // 1 hour
});
res.redirect('/dashboard');
});
Even Better: Store tokens server-side with session ID:
// Store tokens in Redis/database
await redis.set(`session:${sessionId}`, JSON.stringify(tokens));
// Only send session ID to client
res.cookie('session_id', sessionId, {
httpOnly: true,
secure: true,
sameSite: 'strict'
});
4. Not Using PKCE (The Cool Kid in Town) ๐ธ
What is PKCE? Proof Key for Code Exchange (pronounced "pixy")
It's an extra security layer that prevents authorization code interception. Originally for mobile apps, but everyone should use it now!
Without PKCE (vulnerable):
User โ OAuth Provider (gets code) โ Hacker intercepts โ ๐
With PKCE (protected):
const crypto = require('crypto');
// Step 1: Generate code verifier
function generateCodeVerifier() {
return crypto.randomBytes(32).toString('base64url');
}
// Step 2: Create code challenge
function generateCodeChallenge(verifier) {
return crypto
.createHash('sha256')
.update(verifier)
.digest('base64url');
}
// Step 3: Start OAuth flow
app.get('/auth/google', (req, res) => {
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);
// Store verifier in session
req.session.codeVerifier = codeVerifier;
const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?
client_id=${CLIENT_ID}&
redirect_uri=${REDIRECT_URI}&
response_type=code&
code_challenge=${codeChallenge}&
code_challenge_method=S256`; // โ
SHA-256 hash
res.redirect(authUrl);
});
// Step 4: Exchange code with verifier
app.get('/oauth/callback', async (req, res) => {
const { code } = req.query;
const codeVerifier = req.session.codeVerifier;
// Send verifier to prove it's the same client
const tokens = await exchangeCodeForToken(code, codeVerifier);
// Even if hacker steals code, they don't have verifier!
res.json({ success: true });
});
Why it works: The hacker might intercept the code, but they don't have the original code_verifier. Without both, they can't get tokens! ๐
5. Not Validating Access Tokens ๐ซ
The Mistake: Trusting tokens blindly
Just because someone gives you a token doesn't mean it's valid or for your app!
The Bad Way:
// ๐จ Accepting any token without validation
app.get('/api/user', (req, res) => {
const token = req.headers.authorization;
const user = await fetchUserWithToken(token); // YOLO!
res.json(user);
});
The Safe Way:
async function validateAccessToken(token) {
try {
// Option 1: Introspect token with OAuth provider
const response = await fetch('https://oauth.provider.com/introspect', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `token=${token}&client_id=${CLIENT_ID}&client_secret=${CLIENT_SECRET}`
});
const data = await response.json();
// Verify token is active
if (!data.active) {
throw new Error('Token is not active');
}
// Verify audience (token is for YOUR app)
if (data.aud !== CLIENT_ID) {
throw new Error('Token is for different client');
}
// Verify expiration
if (data.exp < Date.now() / 1000) {
throw new Error('Token expired');
}
return data;
} catch (error) {
throw new Error('Invalid token');
}
}
app.get('/api/user', async (req, res) => {
const token = req.headers.authorization?.replace('Bearer ', '');
try {
const tokenData = await validateAccessToken(token);
const user = await fetchUserWithToken(token);
res.json(user);
} catch (error) {
res.status(401).json({ error: 'Unauthorized' });
}
});
The OAuth Security Checklist โ
Before you ship OAuth to production:
- Whitelist exact redirect URIs (no wildcards!)
- Generate and validate
stateparameter (CSRF protection) - Implement PKCE for all flows (yes, even web apps)
- Store tokens in httpOnly cookies or server-side
- Validate tokens on every API request
- Use HTTPS everywhere (OAuth over HTTP = ๐)
- Set short token expiration (15-60 minutes)
- Implement refresh token rotation
- Log all OAuth events for monitoring
- Rate limit token endpoints (prevent brute force)
Common OAuth Scopes: Ask for Less! ๐ฏ
Bad: Requesting all the permissions
scope: 'user:email read:user admin:org delete:repo' // ๐จ Too much!
Good: Only what you need
scope: 'user:email' // โ
Minimal access
Pro Tip: Users are more likely to grant OAuth if you ask for less. Don't be that app asking for kitchen sink permissions!
Framework-Specific Tips ๐ ๏ธ
Laravel (Socialite):
// Built-in state validation!
return Socialite::driver('google')
->stateless() // For API usage
->redirect();
// Callback
$user = Socialite::driver('google')->user();
Node.js (Passport.js):
passport.use(new GoogleStrategy({
clientID: CLIENT_ID,
clientSecret: CLIENT_SECRET,
callbackURL: "https://yoursite.com/auth/callback",
passReqToCallback: true // Access request object
}, validateUser));
Real-World War Story ๐
In security communities, there's a famous case where a major app had their OAuth redirect validation bypassed using:
https://yoursite.com.evil.com
The regex validation looked for yoursite.com anywhere in the domain. The fix? Exact string matching with a whitelist!
Lesson learned: Security is about being paranoid. If you think "nobody would try that," someone already has! ๐
Testing Your OAuth Implementation ๐งช
Use these tools:
- OAuth.tools - Visualize your OAuth flow
- JWT.io - Decode and inspect tokens
- Burp Suite - Intercept and modify OAuth requests
- OWASP ZAP - Automated security testing
Manual tests to run:
# Test: Missing state parameter
curl "https://yourapp.com/oauth/callback?code=VALID_CODE"
# Test: Manipulated redirect_uri
curl "https://yourapp.com/oauth/callback?code=VALID_CODE&redirect_uri=https://evil.com"
# Test: Expired token
curl -H "Authorization: Bearer EXPIRED_TOKEN" "https://yourapp.com/api/user"
The TL;DR ๐
OAuth 2.0 is like giving someone keys to your house - you better make sure:
- You only give keys to the right person (validate redirect URIs)
- You can verify they're the same person who asked (use state parameter)
- The keys expire (short-lived tokens)
- You can revoke keys if needed (token revocation)
- Nobody can intercept the keys (PKCE + HTTPS)
Think of OAuth security as layers of an onion - each layer makes attackers cry a little more! ๐ง ๐ญ
Got OAuth horror stories? I'd love to hear them! Connect with me on LinkedIn.
Want to dive deeper into web security? Follow this blog - I share practical security tips from 7+ years of building production systems and participating in security communities!
Now go forth and OAuth securely! ๐โจ