OAuth & Social Login
Stage 3 · Auth, Identity & Security · B.U.I.L.D. letter: L
You just dropped a "Sign in with Google" button. Three seconds of copy-paste and it works — that's the magic of vibe coding. But behind that button is a multi-step dance between three servers, two secrets, and at least one trap that can turn your slick app into a credential-harvesting disaster. This lesson is the map.
⚠️ The vibe trap
The most common OAuth mistake is treating it like a front-end problem. Vibe coders who wire up a client library, receive the access token in the browser, and then send that token to their own backend have just handed an attacker the keys — any script on the page can steal it. Equally dangerous: skipping the state parameter because "it seems optional." Without state, a malicious link can hijack your user's login and attach their session to the attacker's account. OAuth is a three-party protocol; respecting all three parties is non-negotiable.
🔑 What OAuth 2.0 Actually Is (Delegated Authorization)
OAuth 2.0 is not an authentication protocol — it is an authorization protocol that the industry co-opted for login. When a user clicks "Sign in with Google," they are telling Google: "Let this app see my email address on my behalf." Your app never receives the user's Google password. Google hands you a limited-scope token; you use that token to fetch the profile; you issue your own session from there.
Mental model: Think of it like a hotel key card. The guest (user) asks the front desk (Google) for a key card (token) scoped to Room 304 only (your app's requested scopes). Your app is a door lock — it checks the key card but never knew the master PIN.
Why this matters: If your token leaks, the attacker can only do what the scopes permit (e.g., read the email). They cannot log into Google as the user. Damage is bounded.
# The scopes your app requests determine what data you can access.
# Least-privilege: only ask for what you need.
GET /o/oauth2/v2/auth
?client_id=YOUR_CLIENT_ID
&redirect_uri=https://yourapp.com/auth/callback
&response_type=code
&scope=openid%20email%20profile
&state=RANDOM_CSRF_TOKEN
&access_type=offline
Host: accounts.google.com
Common mistake: Requesting scope=* or every available permission because it's easier. Providers flag over-scoped apps, and users reject them.
🔄 The Authorization Code Flow — Step by Step
This is the only flow you should use for apps with a backend. Here is the complete round-trip.
1. Your front end redirects the user to the provider's authorization URL.
2. The user logs in (at Google/GitHub, NOT your app) and clicks "Allow."
3. The provider redirects back to YOUR redirect_uri with a short-lived `code`.
4. YOUR SERVER exchanges the code for tokens by calling the provider's token endpoint.
5. Your server fetches the user's profile using the access token.
6. Your server looks up or creates the user record in your DB.
7. Your server issues its own session/JWT and sends that to the front end.
Step 4 is the critical boundary. The code exchange must happen server-side — the client never sees your client_secret, and the access token never travels through the browser's address bar.
// Step 1 — Build the redirect URL on your server (Node/Express example)
import crypto from 'node:crypto';
import { URLSearchParams } from 'node:url';
function buildAuthorizationUrl(req, res) {
// Generate a cryptographically random state token and stash it in the session
const state = crypto.randomBytes(16).toString('hex');
req.session.oauthState = state;
const params = new URLSearchParams({
client_id: process.env.GOOGLE_CLIENT_ID, // public — OK in URL
redirect_uri: 'https://yourapp.com/auth/callback',
response_type: 'code',
scope: 'openid email profile',
state, // CSRF guard
access_type: 'offline', // request a refresh token
});
res.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params}`);
}
Common mistake: Building the authorization URL on the client (React, Svelte, etc.) and never storing state server-side. If state lives only in localStorage, a same-origin XSS can read and reuse it.
🔐 The Server-Side Code → Token Exchange
When Google redirects back to /auth/callback, your server receives a code query parameter. That code is single-use, expires in ~10 minutes, and is meaningless without your client_secret. This is why the exchange must happen on your backend.
// Step 3–4 — Verify state, exchange code for tokens (server-side)
import axios from 'axios';
async function handleOAuthCallback(req, res) {
const { code, state } = req.query;
// --- VERIFY STATE first, before touching the code ---
if (!state || state !== req.session.oauthState) {
return res.status(403).send('State mismatch — possible CSRF attack.');
}
delete req.session.oauthState; // consume it; don't let it be reused
// --- Exchange code for tokens ---
const tokenResponse = await axios.post(
'https://oauth2.googleapis.com/token',
new URLSearchParams({
code,
client_id: process.env.GOOGLE_CLIENT_ID,
client_secret: process.env.GOOGLE_CLIENT_SECRET, // NEVER exposed to browser
redirect_uri: 'https://yourapp.com/auth/callback',
grant_type: 'authorization_code',
}),
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
);
const { access_token, id_token } = tokenResponse.data;
// --- Step 5: Fetch the user profile ---
const profileResponse = await axios.get(
'https://www.googleapis.com/oauth2/v3/userinfo',
{ headers: { Authorization: `Bearer ${access_token}` } }
);
const { sub, email, name, picture } = profileResponse.data;
// `sub` is Google's stable unique user ID — use this as the foreign key
// --- Step 6–7: Upsert user record, issue your own session ---
const user = await upsertUser({ provider: 'google', providerId: sub, email, name, picture });
req.session.userId = user.id;
res.redirect('/dashboard');
}
Mental model: The code is a claim-check ticket. It proves the user authorized your app, but it has no value alone. Only your server — holding the matching client_secret — can redeem it for real tokens. This is why OAuth is safe over HTTP redirects that pass through the browser.
Why this matters: If you do the token exchange in the browser (a common shortcut in SPAs), any browser extension, XSS payload, or network observer can steal the access token. On the server, that exchange is invisible to the browser.
Common mistake: Using the id_token's email as the primary key rather than sub. Emails can change; sub is Google's immutable stable identifier for a user.
🏗️ Mapping the External Identity to Your User Record
OAuth gives you a profile. Your app needs a user row. The pattern is upsert: find by (provider, providerId); if found, return the user; if not, create a new row.
// A simple upsert function (plain SQL shown — adapt to your ORM)
async function upsertUser({ provider, providerId, email, name, picture }) {
const existing = await db.query(
`SELECT id FROM users
WHERE provider = $1 AND provider_id = $2
LIMIT 1`,
[provider, providerId]
);
if (existing.rows.length > 0) {
// Update display info in case it changed
await db.query(
`UPDATE users SET email=$1, display_name=$2, avatar_url=$3
WHERE provider=$4 AND provider_id=$5`,
[email, name, picture, provider, providerId]
);
return existing.rows[0];
}
// New user — insert a fresh record
const inserted = await db.query(
`INSERT INTO users (provider, provider_id, email, display_name, avatar_url)
VALUES ($1, $2, $3, $4, $5)
RETURNING id`,
[provider, providerId, email, name, picture]
);
return inserted.rows[0];
}
Mental model: Your users table is the source of truth for your app. The OAuth provider is just a trusted identity witness — it vouches for who the person is, but you decide what roles and permissions they get inside your system.
Why this matters: If you ever support multiple providers (Google and GitHub), the same real human might use both. You'll want an oauth_accounts join table linking multiple external identities to one users row — but the simple approach above is the correct starting point.
Common mistake: Creating a new user row every time, using the email as a de-duplication key. Email uniqueness isn't guaranteed across providers, and Google lets users change their email.
🧩 PKCE — For When You Have No Backend
If you're building a pure SPA or a mobile app with no server, you cannot safely store a client_secret. That's what PKCE (Proof Key for Code Exchange, pronounced "pixie") solves.
Instead of a shared secret, PKCE uses a per-request challenge:
// PKCE — generate code_verifier and code_challenge in the client
import { randomBytes, createHash } from 'node:crypto'; // or Web Crypto API
function generatePKCE() {
// 1. A random 43–128 character string
const verifier = randomBytes(32).toString('base64url');
// 2. SHA-256 hash of the verifier, base64url-encoded — this is safe to send in the URL
const challenge = createHash('sha256')
.update(verifier)
.digest('base64url');
return { verifier, challenge };
}
// Add to authorization URL:
// &code_challenge=CHALLENGE
// &code_challenge_method=S256
// When exchanging the code, include:
// &code_verifier=VERIFIER
// The server verifies that hash(verifier) === challenge. No secret needed.
Why this matters: PKCE proves that the entity exchanging the code is the same one that initiated the flow, even without a shared secret. Use it for every public client (SPA, mobile, CLI tool).
Common mistake: Using code_challenge_method=plain (sending the verifier directly as the challenge). This provides no protection. Always use S256.
⚖️ The Trade-Off: Less Burden, New Dependencies
OAuth shifts password management to the provider. No more breach liability for hashed passwords, no reset flows to build. But:
| Gain | Cost |
|---|---|
| No password storage | Provider outage = your login is down |
| No reset/hashing code | Provider can change their API or kill access |
| Trusted 2FA from provider | Users must have a Google/GitHub account |
| Lower phishing risk | You depend on a third party's security |
For most apps serving developers or tech-savvy users, the trade-off is worth it. For apps serving populations without Google accounts, or highly regulated domains, you'll want email+password as a fallback — or a purpose-built identity service (Auth0, Clerk, Supabase Auth) that handles both.
🛠️ Your Mission
Pick one of the following based on where your app is today:
Option A — Map the flow for your own app:
On paper (or a whiteboard tool), draw the seven-step OAuth authorization-code flow annotated with the real URLs and parameter names for one provider you want to support. Mark clearly: which steps happen in the browser, which on your server, and where the client_secret lives.
Option B — Harden an existing integration: If you already have a "Sign in with X" button working, audit it against this lesson:
- Is the
stateparameter generated server-side, stored in the session, verified before accepting the code, and deleted after use? - Does the token exchange happen on your backend (never in the browser)?
- Is
GOOGLE_CLIENT_SECRET(or equivalent) stored only in environment variables, never committed to git? - Are you keying your user lookup on
sub(stable provider ID), not email?
Fix any gap you find, and document what you changed.
✅ You're Done When…
- You have a written or diagrammed version of the seven-step authorization-code flow for your target provider, with each step labeled (browser vs. server) — add it to your Security Audit Checklist as a reference diagram under "OAuth Integration."
- Your
stateparameter is generated with a cryptographically random source (crypto.randomBytesor equivalent), stored server-side in the session, verified on callback, and consumed (deleted) after one use. - Your
client_secretexists only in a.envfile (gitignored) or a secrets manager — never in client-side code, never committed to git. - Your user upsert logic keys on
(provider, provider_id), not on email alone. - You can explain in one sentence why the authorization-code exchange must happen on the server.
➡️ Next: Role-Based Access Control.
Build It Right, Or Don't Build It At All. 🏛️