OAuth 2.0: The 'Login With Google' Button That Can Steal Your Users 🔑
OAuth 2.0: The 'Login With Google' Button That Can Steal Your Users 🔑
You added "Login with Google" to your app in an afternoon. Slick, right? No passwords to store, no email verification flows, instant trust. You felt like a security genius.
Then you learned about OAuth redirect attacks. And the state parameter. And token leakage. And...
Look, OAuth 2.0 is genuinely great. But it's also a minefield with a friendly UI. Let me walk you through the traps developers fall into every single week — and how to dodge them.
What OAuth 2.0 Actually Is (No PhD Required) 🎓
OAuth isn't authentication. It's authorization delegation. The difference matters.
- Authentication: "Prove you're Alice."
- Authorization: "Alice says you can read her Google Drive."
When you do "Login with Google," you're technically abusing OAuth to authenticate — using the fact that Google vouched for the user as proof of identity. It works, but it introduces extra attack surface. OpenID Connect (OIDC) is the proper extension for this, but let's focus on where things go wrong.
The simplified flow looks like this:
Your App → "Please authenticate this user" → Google
Google → "Here's a code, send it back" → Your App
Your App → "Here's the code + my secret" → Google
Google → "Here's an access token" → Your App
Your App → "Who is this token for?" → Google
Google → "It's Alice, email: [email protected]" → Your App
Each arrow is an opportunity for an attacker. Let's go through the biggest ones.
Trap #1: Missing the state Parameter (Hello, CSRF) 🎣
What it is: The state parameter is a random nonce you generate, attach to the OAuth request, and verify when Google sends the user back.
What happens without it:
Attacker initiates OAuth flow → Gets a "half-completed" authorization URL
Attacker tricks YOUR USER into clicking that URL
User authorizes → Google redirects to YOUR callback with the attacker's code
Your app links the attacker's Google account to the victim's account
Attacker logs in as victim. Forever.
This is a real attack. It's called OAuth CSRF and it's stupidly common.
The fix is simple:
import secrets
from flask import session, redirect
def login():
# Generate a random state and store it in the user's session
state = secrets.token_urlsafe(32)
session['oauth_state'] = state
return redirect(
f"https://accounts.google.com/o/oauth2/auth"
f"?client_id={CLIENT_ID}"
f"&redirect_uri={REDIRECT_URI}"
f"&response_type=code"
f"&scope=openid email"
f"&state={state}" # <-- This little guy saves your users
)
def callback():
returned_state = request.args.get('state')
stored_state = session.pop('oauth_state', None)
# If these don't match, someone is messing with you
if not stored_state or returned_state != stored_state:
return "CSRF attack detected!", 400
# Now you can safely exchange the code for a token
code = request.args.get('code')
# ... exchange code for token
One random string, stored in session, verified on return. That's it. If your OAuth implementation doesn't do this, go fix it right now. I'll still be here.
Trap #2: Open Redirect in Your Callback URI 🚪
The scenario: Your redirect URI is registered as https://yourapp.com/callback — but your app also has a redirect parameter somewhere.
# Attacker crafts this URL:
https://yourapp.com/callback?code=LEGIT_CODE&next=https://evil.com/steal-tokens
If your callback blindly redirects to next after processing the OAuth code, the user's session token just went on a field trip to evil.com.
The rule: After OAuth callback, only redirect to relative paths or a strict allowlist.
// Bad - open redirect
const next = req.query.next;
res.redirect(next); // 💣
// Good - only allow relative URLs
const next = req.query.next;
if (next && next.startsWith('/') && !next.startsWith('//')) {
res.redirect(next);
} else {
res.redirect('/dashboard'); // Safe default
}
The //evil.com trick is why you check for the double slash. //evil.com is protocol-relative — browsers treat it as https://evil.com. Classic gotcha.
Trap #3: The Authorization Code is One-Time Use (Treat It That Way) ♻️
OAuth authorization codes are meant to be exchanged exactly once for an access token, then discarded. They're short-lived (usually 10 minutes max).
The mistake: Logging or storing the authorization code anywhere.
# This is in too many production logs right now 😬
logger.info(f"OAuth callback received: code={code}, state={state}")
# ^ Your code is now in your log aggregation system, queryable by anyone
# with log access
Also bad: Passing the code through additional redirect hops where it can leak in Referer headers.
The fix: Exchange the code server-side immediately, never log it, and if exchange fails — treat it as invalid and make the user start over. Don't retry with the same code.
Trap #4: Trusting Email Without Verifying It's Verified ✉️
This one is subtle and nasty.
When you get user info from Google after OAuth, the response includes an email field. Many providers also include an email_verified field. These are not the same thing.
Some OAuth providers let users register with an email they haven't confirmed. If you automatically link accounts by email address:
# DANGEROUS pattern
user_info = google.get_user_info(access_token)
email = user_info['email']
# What if email_verified is False?!
user = User.query.filter_by(email=email).first()
if user:
login_user(user) # Attacker just logged into someone else's account!
The safe version:
user_info = google.get_user_info(access_token)
# Always check this!
if not user_info.get('email_verified'):
return "Email not verified with this provider", 400
email = user_info['email']
# Now it's safe to use for account matching
This matters even more when you support multiple OAuth providers. If I can create a GitHub account with your email before you do, and you later add "Login with GitHub"... you see where this goes.
Trap #5: Storing Access Tokens Like They're Cheap 💸
Access tokens are credentials. Treat them like passwords.
Where developers accidentally leak tokens:
- URL parameters:
https://yourapp.com/dashboard?token=ya29.abc123(shows in browser history, server logs, Referer headers) localStorage(vulnerable to XSS — we've been over this)- Unencrypted database columns with no audit logging
- Analytics events:
trackEvent('oauth_success', { token: accessToken })(yes, this happens)
The right approach:
- Store tokens server-side, associated with the user's session
- If you must store on client, use
httpOnlycookies - Encrypt tokens at rest if storing in a database
- Scope tokens minimally — don't request
drive.fullif you only needemail - Refresh tokens when they expire; revoke them when users disconnect
The OAuth Security Checklist 🛡️
Before you ship your OAuth integration:
-
stateparameter generated per-request and verified on callback - Redirect URIs strictly registered with provider (no wildcards!)
- Authorization codes exchanged immediately and never logged
-
email_verifiedchecked before trusting email for account linking - Access tokens stored server-side or in
httpOnlycookies - Minimal scopes requested (only what you actually need)
- Token refresh implemented (access tokens expire)
- Revocation endpoint called when users disconnect the integration
- Post-OAuth redirects use allowlist, not open redirect
Real Talk: Use a Library 📦
Implementing OAuth correctly from scratch is hard. The above covers the main pitfalls, but there are more edge cases lurking. For production apps:
- Node.js:
passport.js+passport-google-oauth20 - Python:
Authlib,python-social-auth - PHP/Laravel:
Laravel Socialite - Go:
golang.org/x/oauth2
These libraries handle the state parameter, token exchange, and refresh flows correctly. Don't reinvent this wheel unless you're writing a security paper about it.
The Bottom Line
OAuth is powerful, widely supported, and when implemented correctly — genuinely secure. The attacks above aren't theoretical. They show up in bug bounty reports weekly.
The good news: every single one of them has a simple fix.
- Always use the
stateparameter — CSRF protection for free - Validate redirect targets — no open redirects after callback
- Never log authorization codes — they're short-lived credentials
- Check
email_verified— not all emails are created equal - Treat tokens like passwords — server-side storage, minimal scopes
Do these five things and your "Login with Google" button will be the secure, smooth experience your users deserve — not a backdoor for attackers.
Found an OAuth misconfiguration in the wild? Share your story on LinkedIn — the security community learns from these.
More security deep-dives incoming! Because there's always another footgun to document. 🔐
P.S. — If your OAuth callback logs the authorization code "just for debugging," you're one Splunk query away from a bad day. Go fix that. 🛡️