"Sign in with Google." "Connect your GitHub." You've used these a hundred times and watched the dizzying chain of redirects. Today that chain stops being magic. OAuth2 is a protocol for one app to get limited access to your stuff on another app — without ever seeing your password. The whole design hangs on one clever split, and you'll watch it run on your machine.
🎯 Mission: a mental model of modern auth + the ability to wire it into a real appOAuth2 splits the handoff across two channels so no single intercepted message is enough. A short-lived, near-useless authorization code travels through your browser (the exposed front channel). The powerful access token is then fetched server-to-server (the hidden back channel) using a client secret your browser never sees. The redirects you've always found confusing are the front channel doing its job.
End of Lesson 4 we hit the wall: passwords don't share. If MyApp wants to show your Google Photos, the terrible old way was to ask for your Google password and log in as you — handing a third party your master key, with no limits and no revocation. OAuth2 exists to kill that anti-pattern.
You. You own the photos and get to grant access.
The app that wants in — "MyApp". Pre-registered with the provider; holds a client_id (public) and often a client_secret (private).
Where you log in & consent — Google's/PhotoCloud's login. Issues codes and tokens.
The API holding your stuff (the Photos API). Accepts the access token.
Plus three things that get passed around: a scope (the specific permission, e.g. photos:read), an authorization code (a short-lived one-time ticket), and an access token (the actual bearer pass to the API — a token exactly like Lesson 3's).
Here's the entire authorization-code dance. Watch which arrow goes through the browser and which goes server-to-server — that's the whole trick.
I put a zero-dependency simulation on your machine. It runs all five steps and prints which message crosses which channel — then shows an attacker who stole the browser-side code failing, because they lack the secret.
# in your terminal (no npm install needed):
cd ~/projects/learn/public/courses/auth/practice/oauth-lab
node demo.js
STEP 3 (front channel): browser gets …/callback?code=2b83d6…&state=… STEP 4 (back channel): MyApp POSTs code + client_secret=•••••• → access_token WHY TWO CHANNELS? attacker intercepts the front-channel CODE, tries to redeem it… 🛡️ rejected → "bad client credentials" The code is worthless without the client_secret, which lives only on MyApp's server.That rejection is the entire reason OAuth looks the way it does. The code is exposed (it's in a URL, in the browser, in logs) — and that's fine, because redeeming it requires a secret that never left the server. Open
demo.js; every FRONT/BACK CHANNEL label maps to an arrow in the diagram above.| The confusing part | Why it's there |
|---|---|
| All the redirects | So you authenticate on the provider's own site — MyApp never sees your password. The redirect is the security boundary. |
| A code first, then a token (two steps) | The code can leak through the browser harmlessly; the token (which actually opens the API) is only ever fetched over the secret-protected back channel. |
state parameter | CSRF protection (Lesson 2!). MyApp sends a random state, checks it on return — so an attacker can't forge the callback. |
Exact redirect_uri matching | The provider only ever sends the code to a pre-registered URL, so it can't be redirected to an attacker's site. |
scope | Least privilege: MyApp gets photos:read, not your whole account. You see and consent to exactly that. |
What it looks like in Express — the client side is just two routes (start + callback):
app.get('/connect', (req, res) => { // ① kick off req.session.oauthState = crypto.randomBytes(8).toString('hex'); const u = new URL('https://provider.example/authorize'); u.searchParams.set('response_type', 'code'); u.searchParams.set('client_id', CLIENT_ID); u.searchParams.set('redirect_uri', 'https://myapp.example/callback'); u.searchParams.set('scope', 'photos:read'); u.searchParams.set('state', req.session.oauthState); res.redirect(u.toString()); // → front channel }); app.get('/callback', async (req, res) => { // ③ provider redirected back if (req.query.state !== req.session.oauthState) return res.status(403).end(); // CSRF check const r = await fetch('https://provider.example/token', { // ④ BACK channel method: 'POST', body: new URLSearchParams({ grant_type: 'authorization_code', code: req.query.code, client_id: CLIENT_ID, client_secret: CLIENT_SECRET, redirect_uri: 'https://myapp.example/callback' }), }); const { access_token } = await r.json(); // never reaches the browser // store access_token server-side, tied to this user's session (Lesson 2) });
This back-channel design assumes the client can keep a secret — true for a server (a "confidential client"). But a single-page app or mobile app has no secret: anything shipped to the browser/device can be extracted. No secret → step ④ can't be protected → a stolen code could be redeemed by anyone.
Q1. Why doesn't it matter much if the authorization code is exposed in the browser URL / server logs?
Because redeeming the code for a token requires the client secret over the back channel — which the browser never has. The code alone is a single-use, short-lived ticket that's useless without the secret. You watched the attacker fail with "bad client credentials" for exactly this reason. (§04.) (This is also why public clients with no secret are a problem → PKCE.)
Q2. What's the state parameter for, and which earlier lesson does it connect to?
CSRF protection (Lesson 2). MyApp generates a random state, stashes it in the session, sends it in step ①, and verifies the value coming back in step ③ matches. Without it, an attacker could trick your browser into completing a callback the attacker initiated. (§05.)
Q3. A junior dev says "let's just use the Implicit flow, it's simpler — the token comes straight back." Your response?
Don't — the Implicit flow is removed in OAuth 2.1 because handing the access token through the front channel (browser/URL) exposes it to leakage with no back-channel protection. The modern approach is the authorization-code flow + PKCE, which is secure even without a client secret and is now mandatory. (§06.)
You can now read any OAuth2 flow and say what crosses which channel and why. Lesson 6 closes the two open threads: PKCE (how public apps with no secret do this safely — and you'll generate the verifier/challenge yourself), and OIDC (the thin layer that turns "access my photos" into "log me in" — i.e. "Sign in with Google" end to end, including the ID token, which is just a JWT from Lesson 3).
💬 Ask me anything — e.g. "wire this against a real provider (GitHub) in Express", "what exactly is in the access token?", or "where do refresh tokens fit?" · Back to Lesson 4 · Resources.