0x55aa
โ† Back to Blog

OAuth 2.0 Security Pitfalls: When 'Login with Google' Goes Wrong ๐Ÿ”

โ€ข9 min read

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:

  1. Hacker starts OAuth flow, gets their own code
  2. Tricks victim into clicking malicious link with hacker's code
  3. Victim's account gets linked to hacker's OAuth account
  4. 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 state parameter (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:

  1. OAuth.tools - Visualize your OAuth flow
  2. JWT.io - Decode and inspect tokens
  3. Burp Suite - Intercept and modify OAuth requests
  4. 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:

  1. You only give keys to the right person (validate redirect URIs)
  2. You can verify they're the same person who asked (use state parameter)
  3. The keys expire (short-lived tokens)
  4. You can revoke keys if needed (token revocation)
  5. 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! ๐Ÿ”โœจ