Lesson 5 left two threads dangling: how does a public app with no secret (an SPA or mobile app) do OAuth safely? And how does "let me read your photos" become "log me in"? The answers — PKCE and OIDC — complete the picture, and together they're how every "Sign in with Google" button on earth actually works. You'll generate a real PKCE pair and decode a real ID token today.
🎯 Mission: a mental model of modern auth + the ability to wire it into a real appPKCE replaces the fixed client secret with a fresh per-request secret the attacker can't fake; OIDC adds an ID token that says who you are. PKCE: send a hash up front, prove you know the original later — so a stolen code is useless without the original. OIDC: ask for the openid scope and you also get a signed JWT (Lesson 3) describing the user. Authorization-code + PKCE + OIDC is modern "Sign in with X."
Lesson 5's security rested on the client secret protecting the back-channel token exchange. But an SPA or mobile app is a public client — every byte ships to the user's device, so it can't hold a secret (anyone can pop open DevTools or decompile the app and read it). Without a secret, a stolen authorization code could be redeemed by an attacker. PKCE closes that hole without any pre-shared secret.
Three moves, all built on a one-way hash (SHA-256, exactly the irreversibility idea from Lesson 4's password hashing):
BASE64URL(SHA256(verifier)) and sends only this hash in the front-channel authorize request.A zero-dependency lab runs both halves of this lesson: it builds a real verifier/challenge pair, redeems it, shows an attacker failing, then decodes an OIDC ID token.
# in your terminal (no npm install needed):
cd ~/projects/learn/public/courses/auth/practice/pkce-lab
node demo.js
PART A — PKCE: code_verifier = MALyC6frNqopvqxZQ0wv9gB0mv_f5DCxt9fVhEUKRoU (secret, stays local) code_challenge = Z4_5_NoCWdm_Nbtja8BpcjByigxrOX2j8Z94zkplQX4 (= SHA256, sent first) ✅ provider: SHA256(verifier) === stored challenge → issues token 🛡️ attacker with the stolen code but a guessed verifier → "PKCE verification failed" PART B — OIDC id_token (a JWT) decoded: { "iss":"accounts.google.com", "aud":"spa-app", "sub":"110169484474386276334", "email":"alex@example.com", "name":"Alex" }Two payoffs in one run: you computed the PKCE challenge and saw why reversing it is hopeless, and you saw that "who logged in" is literally just JWT claims you already know how to read. Change
codeVerifier before the redeem in demo.js and watch the legit exchange fail too — the binding is exact.Authorization-code + PKCE, public-client style — note there's no client_secret anywhere:
const crypto = require('crypto'); const b64url = (b) => b.toString('base64url'); app.get('/login', (req, res) => { const verifier = b64url(crypto.randomBytes(32)); // 1. per-request secret const challenge = b64url(crypto.createHash('sha256').update(verifier).digest()); req.session.pkceVerifier = verifier; // keep it server-side req.session.state = b64url(crypto.randomBytes(8)); // CSRF (Lesson 2) const u = new URL('https://accounts.google.com/o/oauth2/v2/auth'); Object.entries({ response_type:'code', client_id:CLIENT_ID, redirect_uri:REDIRECT_URI, scope:'openid email profile', // openid → OIDC! state:req.session.state, code_challenge:challenge, code_challenge_method:'S256' }).forEach(([k,v]) => u.searchParams.set(k, v)); res.redirect(u.toString()); }); app.get('/callback', async (req, res) => { if (req.query.state !== req.session.state) return res.status(403).end(); const r = await fetch('https://oauth2.googleapis.com/token', { method:'POST', body: new URLSearchParams({ grant_type:'authorization_code', code:req.query.code, client_id:CLIENT_ID, redirect_uri:REDIRECT_URI, code_verifier:req.session.pkceVerifier }) }); // 3. prove it — no secret const { id_token, access_token } = await r.json(); const user = await verifyIdToken(id_token); // 4. VERIFY (never decode-and-trust) req.session.userId = user.sub; // 5. your own session begins res.redirect('/account'); });
verifyIdToken is not optional
An ID token is a JWT, so all of Lesson 3 applies: verify the signature against the provider's published keys (its JWKS), and check iss, aud, and exp. Decoding the payload and trusting it is the classic OIDC vulnerability. In practice use a vetted library (e.g. openid-client) rather than hand-rolling. See Google — validating an ID token.Plain OAuth2 gives you an access token — permission to call an API. It deliberately says nothing reliable about who the user is. OpenID Connect (OIDC) is a thin standard layer on top that adds exactly that: ask for the openid scope and the provider also returns an ID token — a signed JWT whose claims identify the user.
| Access token | ID token (OIDC) | |
|---|---|---|
| Question it answers | What may you do? (authZ) | Who are you? (authN) |
| Audience (who reads it) | The API / resource server | Your app (the client) |
| Format | Opaque or JWT (provider's choice) | Always a JWT — for you to read |
| Key claim | scope | sub — stable unique user id |
| You should… | Send it to the API, don't inspect it | Verify it, then read sub/email |
sub, not email, as the user key
sub is the provider's stable, unique id for the user — it never changes. Email can change or be reassigned. Key your user records on (iss, sub). And note the handoff (again): after OIDC, you call req.session.userId = user.sub and you're back to Lesson 2's session. OIDC just authenticated the user; staying logged in is still your own session/token."Sign in with Google" is not a new thing — it's everything you've already learned, stacked:
The authorization-code flow + two channels — the skeleton.
Makes it safe without a client secret — so SPAs/mobile/everyone.
Adds a JWT that says who — verify it with Lesson 3's rules.
Read sub, set an HttpOnly cookie — you're logged in.
Q1. Why can PKCE protect a public app (no secret) when the plain authorization-code flow couldn't?
PKCE creates a fresh per-request secret (the verifier) instead of relying on a fixed, pre-shared client secret. The front channel only ever carries the SHA-256 hash (challenge); the raw verifier is revealed only on the back channel. Since SHA-256 can't be reversed, a thief with the stolen code+challenge can't produce a matching verifier — you watched it fail with "PKCE verification failed." No secret ever needed to ship to the device. (§02–§03.)
Q2. What's the difference between the access token and the ID token, and which one does your app inspect?
Access token = authorization ("what may you do"), meant for the API — your app should treat it as opaque and just forward it. ID token = authentication ("who are you"), a JWT meant for your app to read after verifying it; you pull sub (and maybe email/name) from it to establish identity. So: inspect the ID token, forward the access token. (§05.)
Q3. You receive an ID token, base64-decode the payload, see email: ceo@company.com, and log the user in as the CEO. What did you skip, and why is it dangerous?
You skipped verification — the JWT signature (against the provider's JWKS) plus iss/aud/exp checks. An ID token's payload is just readable base64 (Lesson 3): anyone can forge one with any email. Without checking the signature you'll trust an attacker-minted token. Always verify before trusting; key on sub, not email. (§04 warning, §05.)
You've now assembled the full password-free login. But every token-based scheme in this course has shared one nagging loose end: tokens expire, and you can't easily revoke them (Lessons 1 & 3). Lesson 7 tackles the token lifecycle head-on — refresh tokens, rotation, logout, and revocation: how real systems keep you logged in for weeks while keeping access tokens short-lived, and how they actually kill a session when you click "log out everywhere."
openid scope that also returns a signed ID token (a JWT) telling you who logged in. Authorization-code + PKCE + OIDC = modern "Sign in with X."💬 Ask me anything — e.g. "wire real Google sign-in with openid-client in Express", "show me a provider's JWKS and how verification uses it", or "should my app use Google directly or a broker like Auth0/Clerk?" · Back to Lesson 5 · Lesson 3 (JWTs) · Resources.