OAuth 2.0 & Social Login
The Authorization Code Flow
LinkedIn Hook
"Why does 'Login with Google' feel like magic — and why do most developers get it dangerously wrong?"
Every modern app has a
Login with GoogleorLogin with GitHubbutton. Click it, and seconds later you're signed in without ever typing a password into the app. No new credentials to remember. No password reset emails. No leaked database of bcrypt hashes.But under the hood, OAuth 2.0 is a carefully choreographed dance between four parties: the user, your app, the provider (Google/GitHub), and the user's browser. Skip a single step — like the
stateparameter — and your login button becomes a CSRF vulnerability. Leak yourclient_secretto the frontend, and attackers can impersonate your entire application.Most developers reach for
passport-google-oauth20, slap it into Express, and never read the spec. That works until something breaks — and then they have no idea why.In Lesson 8.3, I break down the Authorization Code Grant flow step by step, build a "Login with GitHub" integration from scratch (no Passport), then show the Passport equivalent, and explain PKCE, state parameters, and account linking — the things interviewers actually probe.
Read the full lesson -> [link]
#NodeJS #OAuth2 #Authentication #Security #BackendDevelopment #InterviewPrep
What You'll Learn
- The Authorization Code Grant flow, step by step, in plain English
- Why PKCE exists and when public clients (mobile, SPA) must use it
- Building "Login with GitHub" from scratch — redirect, callback, token exchange, profile fetch
- The Passport.js equivalent and when the abstraction is worth it
- How to store OAuth user data in your database
- Linking multiple providers (Google + GitHub + email) to one account
- The
stateparameter and why skipping it opens a CSRF hole - The difference between
client_id,client_secret,code, andaccess_token
The Hotel Concierge Analogy — Why OAuth Exists
Imagine you check into a fancy hotel. The concierge offers to book a restaurant for you, but the restaurant requires ID verification. You have two choices:
Option A — Hand the concierge your passport. They walk it across the street, the restaurant copies it, and your passport (with its full identity, address, expiration date) is now in two places — the hotel and the restaurant. If either is dishonest or careless, your data leaks.
Option B — Use a temporary token. The concierge calls the front desk, who verifies your identity once. The front desk hands the concierge a single-use slip of paper that says "Guest in Room 412 is verified, valid until 9pm tonight." The concierge takes that slip to the restaurant. The restaurant trusts the front desk, accepts the slip, and serves you. Your passport never leaves your pocket.
OAuth 2.0 is Option B. Your password is your passport. The provider (Google, GitHub) is the front desk. Your app is the concierge. Instead of handing your password to every app you sign into, you authenticate once with the provider, and the provider issues a short-lived access token that the app can use to fetch a limited, scoped view of your identity.
The user never types their Google password into your app. Your app never sees the password. If your database leaks, no Google passwords leak with it. That is the entire point of OAuth.
+---------------------------------------------------------------+
| AUTHORIZATION CODE GRANT — THE FULL FLOW |
+---------------------------------------------------------------+
| |
| USER BROWSER YOUR APP (Node) PROVIDER |
| | | (GitHub) |
| | | | |
| 1. Click "Login | | |
| with GitHub" | | |
| |--------------------->| | |
| | | | |
| | 2. 302 Redirect to GitHub authorize URL | |
| |<---------------------| | |
| | (with client_id, redirect_uri, state) | |
| | | | |
| 3. Browser follows redirect to GitHub | |
| |------------------------------------------->| |
| | | | |
| 4. User logs in to GitHub & approves scopes | |
| |<------------------------------------------>| |
| | | | |
| 5. GitHub redirects back to your callback URL | |
| | with ?code=ABC123&state=XYZ | |
| |<-------------------------------------------| |
| | | | |
| |--------------------->| | |
| | GET /auth/callback?code=ABC123&state=XYZ| |
| | | | |
| | | 6. Verify state | |
| | | matches session | |
| | | | |
| | | 7. POST to /token | |
| | |--------------------->| |
| | | (code + secret) | |
| | | | |
| | | 8. access_token | |
| | |<---------------------| |
| | | | |
| | | 9. GET /user | |
| | |--------------------->| |
| | | (Bearer token) | |
| | | | |
| | | 10. user profile | |
| | |<---------------------| |
| | | | |
| | | 11. Upsert user, | |
| | | create session | |
| | | | |
| | 12. 302 Redirect to /dashboard | |
| |<---------------------| | |
| | | | |
+---------------------------------------------------------------+
Napkin AI Visual Prompt: "Dark gradient (#0a1a0a -> #0d2e16). Three vertical lanes labeled 'User Browser', 'Node App', 'Provider'. Numbered arrows looping between lanes showing the 12-step OAuth flow. Node green (#68a063) for the app lane, amber (#ffb020) for the provider lane. White monospace labels. A small key icon glowing where 'access_token' is exchanged. A small shield icon next to the 'state' verification step."
The Players — A Quick Glossary
Before any code, lock these terms in. Interviewers love trick questions that swap them.
- Resource Owner — The user. They own the data on the provider.
- Client — Your app. The thing that wants access.
- Authorization Server — The provider's login screen (e.g.
github.com/login/oauth/authorize). - Resource Server — The provider's API (e.g.
api.github.com/user). - client_id — Public identifier for your app. Safe to expose in the browser URL.
- client_secret — Private password for your app. Server-side only. Never ship to a browser.
- redirect_uri — The exact URL on your app where the provider sends the user back. Must be pre-registered.
- code — Short-lived (usually 60 seconds) one-time authorization code. Useless without the secret.
- access_token — The actual key that grants API access. Treat it like a password.
- state — A random string your app generates to prevent CSRF on the callback.
- scope — A space-separated list of permissions you're requesting (e.g.
read:user user:email).
Manual GitHub OAuth — From Scratch, No Library
The best way to understand OAuth is to implement it without a library. We'll build a complete "Login with GitHub" using only Express and node:crypto.
Step 1 — The Redirect (Kicking Off the Flow)
// auth/github.js
// Manual GitHub OAuth — Step 1: redirect the user to GitHub
import crypto from 'node:crypto';
import express from 'express';
const router = express.Router();
// These come from your GitHub OAuth App settings
// https://github.com/settings/developers
const CLIENT_ID = process.env.GITHUB_CLIENT_ID;
const CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET;
const REDIRECT_URI = 'http://localhost:3000/auth/github/callback';
// GET /auth/github -> redirect user to GitHub's authorize page
router.get('/auth/github', (req, res) => {
// Generate a cryptographically random state value
// This protects against CSRF attacks on the callback
const state = crypto.randomBytes(32).toString('hex');
// Store the state in the user's session so we can verify it later
// The callback will compare the returned state to this one
req.session.oauthState = state;
// Build the GitHub authorize URL with all required parameters
const params = new URLSearchParams({
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: 'read:user user:email', // Permissions we are requesting
state: state, // CSRF protection token
allow_signup: 'true', // Allow new GitHub users to sign up
});
const authorizeUrl = `https://github.com/login/oauth/authorize?${params}`;
// 302 redirect — the browser navigates to GitHub
res.redirect(authorizeUrl);
});
export default router;
The user clicks the button, hits /auth/github, and gets bounced to GitHub's login screen. GitHub shows them which app is requesting access and which scopes it wants. If they approve, GitHub redirects them back to redirect_uri with two query parameters: code and state.
Step 2 — The Callback (Verifying State and Exchanging the Code)
// auth/github.js (continued)
// Manual GitHub OAuth — Step 2: handle the callback
// GET /auth/github/callback?code=...&state=...
router.get('/auth/github/callback', async (req, res) => {
const { code, state, error } = req.query;
// GitHub may return an error if the user denied access
if (error) {
return res.status(400).send(`OAuth error: ${error}`);
}
// Critical CSRF check — the returned state must match what we stored
// If it doesn't, this callback was triggered by an attacker, not the user
if (!state || state !== req.session.oauthState) {
return res.status(403).send('Invalid state parameter — possible CSRF attack');
}
// Clear the state from the session — single-use only
delete req.session.oauthState;
// The code is single-use and expires in ~60 seconds
// We must immediately exchange it for an access token
if (!code) {
return res.status(400).send('Missing authorization code');
}
try {
// Step 3 happens here — exchange the code for an access token
const accessToken = await exchangeCodeForToken(code);
// Step 4 — fetch the user's profile using the access token
const profile = await fetchGitHubProfile(accessToken);
// Upsert the user in our database (find existing or create new)
const user = await upsertOAuthUser('github', profile);
// Create a session for the logged-in user
req.session.userId = user.id;
// Redirect to the app dashboard
res.redirect('/dashboard');
} catch (err) {
console.error('OAuth callback failed:', err);
res.status(500).send('Authentication failed');
}
});
Step 3 — Exchange the Code for an Access Token
// auth/github.js (continued)
// Manual GitHub OAuth — Step 3: token exchange (server-to-server)
async function exchangeCodeForToken(code) {
// POST to GitHub's token endpoint with the code AND the secret
// This call happens server-to-server — the secret never touches the browser
const response = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: {
'Accept': 'application/json', // Ask for JSON instead of form-encoded
'Content-Type': 'application/json',
},
body: JSON.stringify({
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET, // The secret authenticates our app
code: code, // The single-use code from the callback
redirect_uri: REDIRECT_URI, // Must match the original redirect_uri
}),
});
if (!response.ok) {
throw new Error(`Token exchange failed: ${response.status}`);
}
const data = await response.json();
// GitHub returns: { access_token, token_type, scope }
// Other providers may also return refresh_token and expires_in
if (!data.access_token) {
throw new Error(`No access token in response: ${JSON.stringify(data)}`);
}
return data.access_token;
}
Step 4 — Fetch the User Profile
// auth/github.js (continued)
// Manual GitHub OAuth — Step 4: fetch the authenticated user's profile
async function fetchGitHubProfile(accessToken) {
// Fetch the basic user profile
const userRes = await fetch('https://api.github.com/user', {
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/vnd.github+json',
'User-Agent': 'my-node-app', // GitHub requires a User-Agent header
},
});
if (!userRes.ok) {
throw new Error(`Failed to fetch user: ${userRes.status}`);
}
const profile = await userRes.json();
// GitHub doesn't always return the email in /user — it may be private
// We need a separate call to /user/emails to get the verified primary email
const emailRes = await fetch('https://api.github.com/user/emails', {
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/vnd.github+json',
'User-Agent': 'my-node-app',
},
});
const emails = await emailRes.json();
// Find the primary, verified email address
const primaryEmail = emails.find((e) => e.primary && e.verified);
return {
providerId: String(profile.id), // GitHub's numeric user ID (stable)
username: profile.login, // GitHub username (can change!)
displayName: profile.name,
email: primaryEmail ? primaryEmail.email : null,
avatarUrl: profile.avatar_url,
};
}
That's the entire flow. No library, no framework, just HTTP. Once you've written it once by hand, every OAuth provider in the world makes sense — they all follow this exact pattern with slightly different URLs and parameter names.
Storing OAuth User Data — The Database Schema
OAuth introduces a question that password auth doesn't: a single human can sign in via Google, GitHub, and email/password. How do you store that without duplicating users?
The answer: two tables. A users table holds the canonical user identity. A separate oauth_accounts table holds one row per (provider, providerUserId) pair, each linked to a users.id.
-- The canonical user — one row per human
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE,
display_name VARCHAR(255),
avatar_url TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- One row per linked OAuth provider account
-- A single user can have many oauth_accounts rows (Google + GitHub + ...)
CREATE TABLE oauth_accounts (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider VARCHAR(32) NOT NULL, -- 'github', 'google', 'discord'
provider_user_id VARCHAR(255) NOT NULL, -- The provider's stable user ID
access_token TEXT, -- Encrypted at rest
refresh_token TEXT, -- Encrypted at rest
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
-- A given provider account can only be linked to ONE user
UNIQUE (provider, provider_user_id)
);
// db/users.js
// Upsert helper — find existing OAuth user or create a new one
import { db } from './pool.js';
export async function upsertOAuthUser(provider, profile) {
// Step 1: do we already have this exact OAuth account on file?
const existing = await db.query(
`SELECT u.* FROM users u
JOIN oauth_accounts oa ON oa.user_id = u.id
WHERE oa.provider = $1 AND oa.provider_user_id = $2`,
[provider, profile.providerId]
);
if (existing.rows.length > 0) {
// Returning user — just give back the existing user record
return existing.rows[0];
}
// Step 2: maybe a user with this email already exists?
// (They previously signed in via email/password or a different provider.)
let user;
if (profile.email) {
const byEmail = await db.query(
`SELECT * FROM users WHERE email = $1`,
[profile.email]
);
if (byEmail.rows.length > 0) {
user = byEmail.rows[0];
}
}
// Step 3: brand new user — create the users row
if (!user) {
const created = await db.query(
`INSERT INTO users (email, display_name, avatar_url)
VALUES ($1, $2, $3) RETURNING *`,
[profile.email, profile.displayName, profile.avatarUrl]
);
user = created.rows[0];
}
// Step 4: link the OAuth account to the user
await db.query(
`INSERT INTO oauth_accounts (user_id, provider, provider_user_id)
VALUES ($1, $2, $3)`,
[user.id, provider, profile.providerId]
);
return user;
}
Notice the key insight: we never store the user by their GitHub username (profile.login). Usernames change. We store by provider_user_id, which is stable for the lifetime of the GitHub account.
Linking Multiple Providers to One Account
The schema above already supports linking. When a logged-in user clicks "Connect Google to your account," you run the same OAuth flow but with one twist: instead of upserting a new user, you attach the new oauth_accounts row to the currently logged-in user_id.
// auth/link.js
// Link an additional provider to an already-authenticated user
router.get('/auth/google/link', requireAuth, (req, res) => {
const state = crypto.randomBytes(32).toString('hex');
// Mark this flow as a LINKING flow, not a login flow
req.session.oauthState = state;
req.session.linkingUserId = req.session.userId;
const params = new URLSearchParams({
client_id: process.env.GOOGLE_CLIENT_ID,
redirect_uri: 'http://localhost:3000/auth/google/callback',
response_type: 'code',
scope: 'openid email profile',
state: state,
});
res.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params}`);
});
router.get('/auth/google/callback', async (req, res) => {
// ... state check, code exchange, profile fetch (same as before) ...
const profile = await fetchGoogleProfile(accessToken);
if (req.session.linkingUserId) {
// LINKING flow — attach this provider to the existing user
const userId = req.session.linkingUserId;
delete req.session.linkingUserId;
// Make sure this Google account isn't already linked to a DIFFERENT user
const conflict = await db.query(
`SELECT user_id FROM oauth_accounts
WHERE provider = 'google' AND provider_user_id = $1`,
[profile.providerId]
);
if (conflict.rows.length && conflict.rows[0].user_id !== userId) {
return res.status(409).send('This Google account is linked to another user');
}
// Insert the link (idempotent — ignore duplicates)
await db.query(
`INSERT INTO oauth_accounts (user_id, provider, provider_user_id)
VALUES ($1, 'google', $2)
ON CONFLICT (provider, provider_user_id) DO NOTHING`,
[userId, profile.providerId]
);
return res.redirect('/settings/connections');
}
// Otherwise this is a normal login flow — upsert as before
const user = await upsertOAuthUser('google', profile);
req.session.userId = user.id;
res.redirect('/dashboard');
});
The Passport.js Equivalent — When the Library Is Worth It
Once you understand the manual flow, Passport becomes obvious: it's a thin wrapper that abstracts steps 1-4 into a "strategy" object. Here's the same GitHub login with passport-github2:
// auth/passport-setup.js
// Passport.js equivalent — same flow, fewer lines
import passport from 'passport';
import { Strategy as GitHubStrategy } from 'passport-github2';
import { upsertOAuthUser } from '../db/users.js';
passport.use(
new GitHubStrategy(
{
clientID: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
callbackURL: 'http://localhost:3000/auth/github/callback',
scope: ['read:user', 'user:email'],
},
// The "verify" callback runs after Passport has done steps 1-4 for you
// It receives the access token and the parsed profile
async (accessToken, refreshToken, profile, done) => {
try {
const user = await upsertOAuthUser('github', {
providerId: profile.id,
username: profile.username,
displayName: profile.displayName,
email: profile.emails?.[0]?.value,
avatarUrl: profile.photos?.[0]?.value,
});
done(null, user);
} catch (err) {
done(err);
}
}
)
);
// How Passport stores users in the session (just the user ID)
passport.serializeUser((user, done) => done(null, user.id));
passport.deserializeUser(async (id, done) => {
const user = await getUserById(id);
done(null, user);
});
// app.js
// Wire Passport into Express
import passport from 'passport';
import './auth/passport-setup.js';
app.use(session({ secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false }));
app.use(passport.initialize());
app.use(passport.session());
// Step 1 — kick off the flow (Passport handles state internally)
app.get('/auth/github', passport.authenticate('github'));
// Step 2-4 — Passport handles state check, code exchange, profile fetch
app.get(
'/auth/github/callback',
passport.authenticate('github', { failureRedirect: '/login' }),
(req, res) => {
// By the time we reach here, req.user is populated
res.redirect('/dashboard');
}
);
When to use Passport: if you need 3+ providers, the strategy ecosystem (over 500 strategies) saves real time. When to skip it: if you only need one provider, the manual implementation is shorter than Passport's setup boilerplate and gives you full control over error handling and session shape.
PKCE — The Extension for Public Clients
The flow above relies on client_secret to prove your app is your app. But what about a mobile app or a single-page app (SPA), where there is no server to keep a secret? You can't ship client_secret in a React Native bundle — anyone can decompile and steal it.
PKCE (Proof Key for Code Exchange, pronounced "pixie") fixes this. Instead of a static secret, the client generates a fresh random secret per login attempt:
// pkce.js
// PKCE flow for public clients (mobile, SPA)
import crypto from 'node:crypto';
// Step 1: generate a high-entropy random string (the "code_verifier")
const codeVerifier = crypto.randomBytes(32).toString('base64url');
// Step 2: hash the verifier with SHA-256 (the "code_challenge")
const codeChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64url');
// Step 3: send the CHALLENGE in the authorize URL (it's safe to expose)
const authorizeUrl = `https://accounts.google.com/o/oauth2/v2/auth?` +
new URLSearchParams({
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: 'code',
scope: 'openid email profile',
state: state,
code_challenge: codeChallenge, // Hashed value (safe in URL)
code_challenge_method: 'S256',
});
// Step 4: when exchanging the code, send the VERIFIER (the original)
// The provider re-hashes the verifier and checks it matches the challenge
// Without the original verifier, an attacker who steals the code can't use it
await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
body: new URLSearchParams({
client_id: CLIENT_ID,
code: code,
code_verifier: codeVerifier, // The original random string
redirect_uri: REDIRECT_URI,
grant_type: 'authorization_code',
}),
});
PKCE is mandatory for public clients in OAuth 2.1 and recommended for everyone, including server-side apps. Many providers (Google, Microsoft, Twitter) accept PKCE alongside the client secret as a defense-in-depth measure.
Common Mistakes
1. Skipping the state parameter.
Without state, an attacker can craft a malicious link like https://yourapp.com/auth/github/callback?code=ATTACKER_CODE and email it to the victim. If the victim is already logged into GitHub and clicks it, your app exchanges the attacker's code, gets the attacker's access token, and links it to the victim's session — silently merging the victim's account with the attacker's. Always generate a random state, store it in the user's session before the redirect, and reject any callback where state doesn't match.
2. Exposing client_secret in the browser.
Putting client_secret in frontend JavaScript, mobile bundles, or environment variables prefixed with NEXT_PUBLIC_ / VITE_ is the same as publishing it on Twitter. Anyone with the secret can impersonate your app. The secret must live only on the server. For browser-based or mobile flows, use PKCE instead — there is no secret to leak.
3. Trusting the email returned by a provider without checking verified.
Some providers return an email field even when the user hasn't verified ownership. An attacker can sign up at GitHub with victim@gmail.com (without verifying it), then use OAuth on your app to "log in" as the victim. Always check email_verified: true (or call GitHub's /user/emails and look at primary && verified).
4. Storing the OAuth username as the primary key.
GitHub usernames, Twitter handles, and Google email addresses can all change. If you key your users table on username, you'll lose user accounts when people rename. Always store by the provider's stable numeric ID (profile.id), and treat the username as a display attribute only.
5. Not handling the "email already exists" case.
A user signs up with email/password as alice@example.com. Months later they click "Login with Google" — and Google returns alice@example.com. If you blindly create a new user, Alice now has two split accounts and her data is fragmented. Either auto-link by verified email, or prompt: "An account with this email already exists. Sign in with your password to link your Google account."
Interview Questions
1. "Walk me through the OAuth 2.0 Authorization Code Grant flow."
The user clicks a "Login with X" button on my app. My app generates a random state value, stores it in the user's session, and redirects the browser to the provider's authorize URL with client_id, redirect_uri, scope, and state as query parameters. The user logs in on the provider's site and approves the requested scopes. The provider redirects the browser back to my app's redirect_uri with two query parameters: a short-lived code and the same state value. My app first verifies that the returned state matches the one in session — that's CSRF protection. Then my app makes a server-to-server POST to the provider's token endpoint, sending the code along with client_id and client_secret. The provider validates everything and returns an access_token (and sometimes a refresh_token). My app uses the access token to call the provider's user info API, gets the user's profile, upserts the user in my database, creates a session, and redirects the browser to the dashboard. The key insight is that the access token is exchanged server-to-server, never through the browser.
2. "What is the state parameter and what attack does it prevent?"
state is a cryptographically random value that the client (your app) generates before redirecting to the provider, stores in the user's session, and includes in the authorize URL. The provider returns the same value in the callback. Your app must verify that the returned state matches the stored one before processing the code. It prevents OAuth CSRF / login-CSRF attacks. Without state, an attacker can obtain their own authorization code, then trick a victim into hitting yourapp.com/callback?code=ATTACKER_CODE. Your app would exchange the attacker's code, get the attacker's access token, and link the attacker's identity to the victim's session — effectively logging the victim into the attacker's account, where the attacker can later read anything the victim does. The state check ensures the callback can only complete a flow that the same browser session originally started.
3. "Why does PKCE exist, and when is it required?"
PKCE (Proof Key for Code Exchange) exists because public clients — single-page apps, mobile apps, desktop apps — can't safely store a client_secret. Anyone can decompile a mobile binary or read the JavaScript bundle and extract the secret. Without a secret, there's nothing stopping an attacker who intercepts a code (e.g. via a malicious app registered to the same custom URL scheme on a phone) from exchanging it for a token. PKCE solves this by generating a fresh random code_verifier per login attempt, sending only its SHA-256 hash (code_challenge) in the authorize URL, and sending the original code_verifier only in the token exchange. The provider re-hashes the verifier and checks it matches the challenge. Even if an attacker steals the code, they don't have the verifier, so the exchange fails. PKCE is mandatory for public clients in OAuth 2.1 and is recommended for confidential (server-side) clients too as defense in depth.
4. "How would you let one user link both Google and GitHub to the same account?"
I'd use two tables: a users table with the canonical identity (id, email, display_name) and an oauth_accounts table with (id, user_id, provider, provider_user_id) and a unique constraint on (provider, provider_user_id). A user can have multiple oauth_accounts rows pointing to the same users.id. When a user is already logged in and clicks "Connect Google," I run the normal OAuth flow but flag the session as a linking flow (e.g. session.linkingUserId = currentUserId). In the callback, instead of upserting a new user, I insert an oauth_accounts row pointing to the existing user_id. Before inserting, I check that the incoming (provider, provider_user_id) isn't already linked to a different user — if it is, I return a 409 conflict. For first-time logins, I also try to auto-link by verified email: if a Google login returns an email that already exists in users, I attach the new oauth_account to that existing user instead of creating a duplicate.
5. "What's the difference between code and access_token? Why have two values instead of one?"
The code is a short-lived (typically 60 seconds), single-use credential that travels through the user's browser as a query parameter. The access_token is a longer-lived credential that grants actual API access and never touches the browser — it's only sent server-to-server. The two-step design exists because the redirect URL is visible everywhere: in browser history, in server access logs, in HTTP referer headers. If the access token were sent in the redirect, all those places would leak it. By sending only a short-lived code in the redirect and exchanging it server-to-server (with client_secret proving the request comes from the legitimate app), the actual access token is never exposed to the browser. Even if an attacker grabs the code from a log file, they can't use it without the client secret, and it expires in 60 seconds anyway. This is also why the implicit flow (which returned the access token directly in the redirect) is now deprecated — it leaked tokens by design.
Quick Reference — OAuth 2.0 Cheat Sheet
+---------------------------------------------------------------+
| OAUTH 2.0 CHEAT SHEET |
+---------------------------------------------------------------+
| |
| THE 4 PARTIES: |
| Resource Owner = the user |
| Client = your app |
| Authorization = provider login screen |
| Resource Server = provider API |
| |
| THE 4 SECRETS: |
| client_id = public, safe in URLs |
| client_secret = SERVER ONLY, never in browser |
| code = single-use, ~60s lifetime |
| access_token = treat like a password |
| |
| THE 5 REQUIRED CHECKS: |
| 1. Generate random state, store in session |
| 2. Verify returned state == stored state |
| 3. Verify email_verified == true |
| 4. Store by provider_user_id, not username |
| 5. Exchange code server-to-server only |
| |
+---------------------------------------------------------------+
+---------------------------------------------------------------+
| THE 12-STEP FLOW (memorize this) |
+---------------------------------------------------------------+
| |
| 1. User clicks "Login with X" |
| 2. App generates state, stores in session |
| 3. App redirects to provider/authorize |
| 4. User logs in to provider |
| 5. User approves scopes |
| 6. Provider redirects to /callback?code=...&state=... |
| 7. App verifies state matches session |
| 8. App POSTs code+secret to /token |
| 9. Provider returns access_token |
| 10. App fetches /user with Bearer access_token |
| 11. App upserts user, creates session |
| 12. App redirects to /dashboard |
| |
+---------------------------------------------------------------+
+---------------------------------------------------------------+
| PKCE (for public clients) |
+---------------------------------------------------------------+
| |
| verifier = random 32 bytes (base64url) |
| challenge = SHA256(verifier) |
| |
| Authorize -> send challenge |
| Token -> send verifier |
| |
| Provider re-hashes verifier, compares to challenge. |
| No client_secret needed. |
| |
+---------------------------------------------------------------+
| Concept | Manual | Passport.js |
|---|---|---|
| Lines of setup | ~80 | ~30 |
| State handling | You write it | Built-in |
| Token exchange | You write it | Built-in |
| Profile parsing | You write it | Strategy normalizes it |
| Multi-provider | Repeat per provider | One strategy per provider |
| Best for | 1 provider, custom flow | 3+ providers, standard flow |
| Visibility into errors | High | Low (debug strategies) |
Prev: Lesson 8.2 -- Session-Based Authentication Next: Lesson 8.4 -- Security Best Practices
This is Lesson 8.3 of the Node.js Interview Prep Course -- 10 chapters, 42 lessons.