🔐 OAuth 2.0 Flow Explained
Every time you click "Sign in with Google," a carefully choreographed handshake runs between your browser, the app, and Google — and nobody ever sees your Google password except Google. That dance is OAuth 2.0. In this lesson you'll learn exactly who does what, in what order, and why each step exists.
Week 9 · Thursday: OAuth and Social Login · Lecture 1
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Explain what OAuth 2.0 is — and distinguish delegated authorization from authentication
- Name the four OAuth roles and map each to a real player in a "Login with X" flow
- Walk through the Authorization Code flow step by step, from redirect to token exchange
- Describe how PKCE protects public clients that can't keep a secret
- Explain how the
stateparameter prevents CSRF and why the client secret must stay server-side - Choose the right grant type for web apps, SPAs, mobile apps, and machine-to-machine calls
Estimated Time: 70 minutes
Practice: Trace an authorization request, build the redirect URL, and reason about a state-mismatch attack.
In This Lesson
What Problem Does OAuth Solve?
Imagine a photo-printing site wants to grab the photos from your Google account. The naive approach would be to ask for your Google email and password and log in as you. That's a disaster: the site now knows your password, can read your email, can change your account, and there's no way to revoke just the printing site without changing your password everywhere.
OAuth 2.0 is the industry-standard protocol that fixes this. It lets you grant an app limited, revocable access to specific parts of your account — without ever handing over your credentials.
🅿️ Real-world analogy: the valet key
Many cars ship with a valet key. It starts the engine and opens the door, but it won't open the trunk or the glovebox. You hand it to a parking attendant so they can park the car — but they can't get into your private compartments, and you keep your real key. An OAuth access token is a valet key for your data: scoped to specific permissions, time-limited, and revocable at any moment without touching your password.
When you click "Sign in with Google," three things are true that make OAuth worth the complexity:
- You never type your Google password into the third-party site — you type it into Google's page.
- You grant a specific scope (say, "read your profile and email") — not blanket access.
- You can visit your Google account settings later and revoke that one app, leaving everything else intact.
The Four Roles
OAuth defines four roles. Everything in the protocol is a message passing between them. Learn these names — the whole spec reads clearly once you do.
| Role | What it is | In "Login with Google" |
|---|---|---|
| Resource Owner | The human who owns the data and can grant access | You |
| Client | The application that wants access on your behalf | The website you're signing in to |
| Authorization Server | Authenticates you and issues tokens | Google's OAuth server (accounts.google.com) |
| Resource Server | Holds the protected data; accepts access tokens | Google's API (e.g. the userinfo endpoint) |
(you)"] -->|grants permission| C["Client
(the app)"] C -->|asks for a token| AS["Authorization Server
(Google login)"] AS -->|issues access token| C C -->|calls API with token| RS["Resource Server
(Google API)"] RS -->|returns your data| C
A few more terms you'll meet constantly:
| Term | Meaning |
|---|---|
access_token | Short-lived credential the client sends on every API call |
refresh_token | Longer-lived credential used to obtain a fresh access token without re-prompting the user |
scope | The specific permissions being requested, e.g. profile email |
client_id | Public identifier for the app, issued when you register it with the provider |
client_secret | Private password for the app — server-side only, never in the browser |
Authorization vs. Authentication
This is the single most important conceptual point in the lesson, and it trips up experienced developers.
- Authentication answers "who are you?" — proving identity.
- Authorization answers "what are you allowed to do?" — granting access.
Plain OAuth 2.0 is a delegated authorization protocol. Its job is to give a client a token that grants access to resources. It was not originally designed to tell the client "who the user is."
⚠️ OpenID Connect adds the identity layer
Because everyone wanted to use OAuth for login, the community built OpenID Connect (OIDC) — a thin layer on top of OAuth 2.0. OIDC adds a signed id_token (a JWT) that securely tells the client who the user is. So "Sign in with Google" is really OIDC on top of OAuth: OAuth authorizes access to the profile, and the id_token authenticates the identity. When you see people say "log in with OAuth," they almost always mean OIDC.
The Authorization Code Flow
The Authorization Code flow is the workhorse of OAuth. It's the flow you'll use for any app with a server backend, and (with PKCE) for SPAs and mobile apps too. The key insight: sensitive token exchange happens server-to-server, out of reach of the browser.
Here is the complete choreography. Read the diagram top to bottom — this is the sequence you'll implement.
Step 1 — Send the user to the authorization server
The client builds a URL to the provider's /authorize endpoint and redirects the browser there. Notice it generates a random state and stores it in the session first.
const crypto = require('crypto');
// Step 1: Redirect the user to the provider's consent screen
app.get('/auth/login', (req, res) => {
// A random, unguessable value tied to THIS browser session (CSRF defense)
const state = crypto.randomBytes(16).toString('hex');
req.session.oauthState = state;
const authUrl = new URL('https://authorization-server.com/oauth/authorize');
authUrl.searchParams.set('response_type', 'code'); // ask for an auth CODE
authUrl.searchParams.set('client_id', process.env.OAUTH_CLIENT_ID);
authUrl.searchParams.set('redirect_uri', process.env.OAUTH_REDIRECT_URI);
authUrl.searchParams.set('scope', 'profile email'); // least privilege
authUrl.searchParams.set('state', state);
res.redirect(authUrl.toString());
});
Why it matters: the browser is only ever redirected to the provider. Your server never sees the user's provider password — that's the whole point.
Step 2 — User authenticates and consents
On the provider's own domain, the user logs in (if not already) and sees a consent screen: "This app wants to view your profile and email. Allow?" The provider — not you — handles passwords, 2FA, and CAPTCHAs.
Step 3 — The provider redirects back with a code
On approval, the provider redirects the browser to your registered redirect_uri with two query parameters: a short-lived code (usually valid ~10 minutes, single use) and the original state.
Step 4 — Exchange the code for tokens (server-side!)
Your server verifies state, then makes a back-channel POST to the token endpoint, including the client_secret. This request never touches the browser.
// Step 3 & 4: Handle the callback and swap the code for tokens
app.get('/auth/callback', async (req, res) => {
const { code, state } = req.query;
// Verify state matches what we stored — blocks CSRF (see next section)
if (!state || state !== req.session.oauthState) {
return res.status(403).send('Invalid state parameter');
}
delete req.session.oauthState; // one-time use
try {
// Back-channel, server-to-server token exchange
const tokenRes = await fetch('https://authorization-server.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: process.env.OAUTH_REDIRECT_URI, // must match step 1 exactly
client_id: process.env.OAUTH_CLIENT_ID,
client_secret: process.env.OAUTH_CLIENT_SECRET // SECRET — server only!
})
});
const { access_token, refresh_token, expires_in } = await tokenRes.json();
// Step 5: use the access token to read the user's profile
const infoRes = await fetch('https://api.example.com/userinfo', {
headers: { Authorization: `Bearer ${access_token}` }
});
const profile = await infoRes.json();
// Create or update the user, then start a session
const user = await findOrCreateUser(profile);
req.session.userId = user.id;
res.redirect('/dashboard');
} catch (err) {
console.error('OAuth error:', err);
res.status(500).send('Authentication failed');
}
});
✅ Why a code instead of a token straight away?
The authorization code travels through the browser (a redirect URL), which is a relatively hostile place — it can end up in history, logs, or a referrer header. But a code is useless on its own: redeeming it requires the client_secret, which lives only on your server. The valuable access_token is delivered over a direct, encrypted, server-to-server channel. That two-step design is exactly why the Authorization Code flow is the secure default.
The state Parameter & CSRF
The state parameter is a random value your app generates, stores in the session, and sends to the authorization server. The server echoes it back on the redirect. Your callback then checks that the returned state equals the stored one.
Without it, you're open to a login CSRF attack: an attacker completes step 1 with their own account, captures the resulting code, and tricks a victim's browser into hitting your callback with that code — silently logging the victim into the attacker's account. Because the attacker never generated the victim's session state, the mismatch check rejects the forged callback.
state value must survive the round-trip and match on return. A mismatch means the callback wasn't started by this browser — reject it.💡 Make state unguessable and single-use
Use a cryptographically random value (crypto.randomBytes), tie it to the server-side session, and delete it after one use. Pairing it with a SameSite=Lax session cookie hardens the flow further.
PKCE for Public Clients
The Authorization Code flow relies on the client_secret to prove the token request is genuine. But a public client — a single-page app or a mobile app — runs entirely on the user's device. Anything you ship there can be extracted, so it cannot keep a secret. That's where PKCE comes in.
PKCE ("Proof Key for Code Exchange," pronounced "pixie") replaces the static secret with a fresh, per-request secret the client makes up on the spot:
- The client generates a random code verifier and keeps it locally.
- It hashes the verifier (SHA-256) to produce a code challenge, which it sends in the authorization request.
- At token-exchange time it sends the original verifier. The server hashes it and checks it matches the challenge it saw earlier.
An attacker who steals the authorization code from the redirect still can't redeem it — they never had the verifier, and they can't reverse the hash.
// Generate a PKCE code verifier and challenge in the browser (Web Crypto API)
function base64UrlEncode(bytes) {
return btoa(String.fromCharCode(...new Uint8Array(bytes)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function generateCodeVerifier() {
const bytes = new Uint8Array(32); // 256 bits of entropy
crypto.getRandomValues(bytes);
return base64UrlEncode(bytes);
}
async function generateCodeChallenge(verifier) {
const data = new TextEncoder().encode(verifier);
const digest = await crypto.subtle.digest('SHA-256', data);
return base64UrlEncode(digest); // send this in the authorize request
}
async function startLogin() {
const verifier = generateCodeVerifier();
sessionStorage.setItem('pkce_verifier', verifier); // keep locally for the swap
const challenge = await generateCodeChallenge(verifier);
const url = new URL('https://authorization-server.com/oauth/authorize');
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', 'YOUR_CLIENT_ID'); // no secret needed
url.searchParams.set('redirect_uri', location.origin + '/callback');
url.searchParams.set('scope', 'profile email');
url.searchParams.set('state', generateCodeVerifier()); // reuse the random helper
url.searchParams.set('code_challenge', challenge);
url.searchParams.set('code_challenge_method', 'S256'); // always S256, never "plain"
location.href = url.toString();
}
✅ PKCE is now recommended for all clients
PKCE started as a fix for mobile and SPA clients, but the OAuth 2.1 draft and current security best-current-practice guidance recommend it for every client — even confidential server apps that already have a secret. It's a cheap, defence-in-depth layer against authorization-code interception. When in doubt, turn it on.
Choosing a Grant Type
OAuth defines several grant types (flows) for different situations. In modern applications your choice is simpler than the full list suggests.
| Grant type | Use it for | Verdict |
|---|---|---|
| Authorization Code | Web apps with a server backend | ✅ The default |
| Authorization Code + PKCE | SPAs, mobile & native apps | ✅ Recommended (and for all clients) |
| Client Credentials | Service-to-service, no user involved | ✅ For backend / machine calls |
| Device Code | Input-limited devices (smart TVs, CLIs) | ✅ For that niche |
| Implicit | Old browser-only apps | ❌ Deprecated — use Code + PKCE |
| Resource Owner Password | Trusted first-party legacy apps | ❌ Avoid — defeats OAuth's purpose |
💡 The Client Credentials flow has no user
When one of your own services calls another's API, there's no human to consent. The Client Credentials flow simply POSTs client_id + client_secret + grant_type=client_credentials to the token endpoint and gets an access token back — no redirect, no browser. Use it for cron jobs, microservice-to-microservice calls, and CI pipelines.
⚠️ Why the Implicit flow was deprecated
The old Implicit flow returned the access token directly in the redirect URL fragment — exposing it to browser history, extensions, and the referrer header, with no code-exchange step to protect it. It has been dropped from OAuth 2.1. If you find it in a tutorial, that tutorial is out of date. Use Authorization Code + PKCE instead.
Practice & Quiz
🏋️ Exercise 1: Build the authorization URL
Goal: Write buildAuthUrl(config, state) that returns the full URL to send a user to a provider's /authorize endpoint for the Authorization Code flow, requesting profile and email scope.
function buildAuthUrl(config, state) {
// config = { authorizeEndpoint, clientId, redirectUri }
// TODO: return the full authorization URL string
}
// Expected: contains response_type=code, the client_id, redirect_uri,
// scope=profile+email, and the state value.
💡 Hint
Use the URL and searchParams APIs. Set response_type to "code", add each parameter with searchParams.set(...), then return url.toString().
✅ Solution
function buildAuthUrl(config, state) {
const url = new URL(config.authorizeEndpoint);
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', config.clientId);
url.searchParams.set('redirect_uri', config.redirectUri);
url.searchParams.set('scope', 'profile email');
url.searchParams.set('state', state);
return url.toString();
}
The URL API handles encoding for you — spaces in the scope become +/%20 automatically, so you never build query strings by hand.
🏋️ Exercise 2: Reject a forged callback
Goal: Write verifyState(returnedState, session) that returns true only when the returned state matches the stored session state and a state was actually stored.
✅ Solution
function verifyState(returnedState, session) {
// Both must exist AND be equal. A missing stored state means
// this browser never started the flow — reject it.
return Boolean(
session.oauthState &&
returnedState &&
returnedState === session.oauthState
);
}
This one check is what defeats login-CSRF: an attacker's forged callback carries a state your session never generated, so the comparison fails.
🎯 Quick Quiz
Question 1: Which OAuth role issues access tokens?
Question 2: What is the state parameter's main job?
Question 3: Why does a single-page app use PKCE instead of a client secret?
Best Practices & Pitfalls
✅ Do
- Use the Authorization Code flow, and add PKCE everywhere
- Generate a random, single-use
stateand verify it on the callback - Exchange the code for tokens server-side, keeping the
client_secretoff the browser - Register exact
redirect_urivalues and validate them — never allow open redirects - Request the minimum scope your app actually needs
- Serve everything over HTTPS; store refresh tokens encrypted at rest
❌ Don't
- Never put the
client_secretin front-end code, a mobile bundle, or a public repo - Don't use the deprecated Implicit flow or the Resource Owner Password grant
- Don't skip
stateverification — it's your CSRF defense - Don't put tokens in URLs or logs; send access tokens only in the
Authorizationheader - Don't treat a plain OAuth access token as proof of identity — use an OIDC
id_tokenfor that
⚠️ The confused-deputy trap
A common mistake is verifying "the token is valid" but not "the token was issued for my app." Always check the token's audience/client and issuer when you validate it, or an attacker can present a token minted for a different application.
Summary
🎉 Key Takeaways
- OAuth 2.0 is delegated authorization — access without sharing passwords; OpenID Connect adds identity
- The four roles are resource owner, client, authorization server, resource server
- The Authorization Code flow sends a code through the browser, then swaps it for tokens server-to-server
- The
stateparameter, verified on return, prevents CSRF - PKCE lets public clients skip the secret; it's now recommended for everyone
- The
client_secretlives only on the server — never in browser or mobile code
📚 Additional Resources
- oauth.net — OAuth 2.0 overview and specs
- oauth.net — Proof Key for Code Exchange (PKCE)
- MDN — The URL API
- RFC 6749 — The OAuth 2.0 Authorization Framework
🚀 What's Next?
You now understand the protocol. Next you'll meet the library that makes it painless in Node.js: Passport.js Strategies — the pluggable middleware that wires Google, GitHub, and dozens of other providers into an Express app.
🎉 Great work!
The OAuth dance stops being mysterious once you can name every dancer. On to implementing it.