Once a user proves who they are, how do you remember that across requests — and how do you let them log in with Google without handling their Google password? The two questions this chapter answers.
Prerequisites: AuthN vs AuthZ, HTTP & TLS Time to read: ~24 minutes
HTTP is stateless — the server forgets you after every request. But you logged in once and expect to stay logged in. So every subsequent request must carry proof of who you are.
Two fundamentally different ways to carry that proof, and choosing between them is a real design decision:
The difference comes down to one question: where does the state live, and can you revoke it instantly?
The server creates a session on login, stores it, and gives the client an opaque session ID (usually in a cookie).
1. Login → server creates session, stores {session_id: {user_id: 42, ...}} in Redis
2. Server sets cookie: session=abc123 (HttpOnly, Secure, SameSite)
3. Every request sends the cookie
4. Server looks up abc123 in Redis → knows it's user 42
5. Logout → delete the session from Redis → instantly invalid
✅ Instant revocation — delete the server-side session and the user is logged out immediately, everywhere. This is the killer feature. ✅ The session ID is opaque; it reveals nothing and can’t be tampered with. ✅ Small cookies.
❌ 🚨 The server is stateful — it must store and look up every session. That store (Redis) must be shared across all app servers (statelessness is broken at the session layer) and is a dependency and a lookup on every request. ❌ Scaling and multi-region add complexity (the session store must be reachable, or replicated).
🚨 The cookie flags are the security story, and they come up in interviews:
HttpOnly — JavaScript can’t read it, defeating token theft via XSS.Secure — HTTPS only.SameSite=Lax or Strict — not sent on cross-site requests, the modern CSRF
defence.The server issues a self-contained, signed token. It carries the identity itself, so the server needs no lookup.
A JWT has three base64 parts, dot-separated:
eyJhbGc... . eyJzdWI... . SflKxw...
header payload signature
header: {"alg": "RS256", "typ": "JWT"}
payload: {"sub": "42", "name": "Bilal", "role": "editor", "exp": 1735689600}
signature: sign(base64(header) + "." + base64(payload), secret_or_private_key)
🚨 The critical property: the signature is verifiable without a database lookup. The server checks the signature (with a shared secret for HMAC, or a public key for RSA/ECDSA) and trusts the claims. No session store, no round trip.
1. Login → server signs a JWT with the user's claims, returns it
2. Client stores it, sends it: Authorization: Bearer <jwt>
3. Every request: server VERIFIES THE SIGNATURE (no lookup) → trusts the claims
4. Logout → ??? (this is the hard part)
✅ Stateless — any server verifies any token with no shared store. Trivial horizontal scaling and multi-region. This is the appeal. ✅ Can carry claims (roles, permissions) so downstream services don’t re-fetch them.
❌ 🚨 Revocation is genuinely hard. A signed JWT is valid until it expires — there’s no server state to delete. You cannot instantly log someone out or kill a compromised token. This is the central JWT trade-off, and it’s the thing interviewers probe. ❌ Larger than a session ID (sent on every request). ❌ 🚨 Storing JWTs safely in a browser is a real problem (see below).
🚨 This is the most important thing to understand about JWTs, and glossing over it is a red flag.
A JWT can’t be un-issued. If a token is stolen, or a user is banned, or you need to force logout, you
can’t just “delete it” — it’s valid until exp. Mitigations, each with a cost:
1. Short expiry + refresh tokens (the standard answer):
Access token: JWT, short-lived (5–15 min), stateless, used on every request
Refresh token: long-lived (days/weeks), stored SERVER-SIDE, used only to get new access tokens
The access token is stateless and fast, but only valid for minutes — so a stolen one is useful briefly. Getting a new access token requires the refresh token, which is stored server-side and can be revoked. 🚨 This is the pattern to describe: stateless access tokens for speed, revocable refresh tokens for control. The window of a compromised access token is bounded by its short expiry.
2. A denylist (blocklist): store revoked token IDs and check on each request. ❌ You’ve reintroduced the server-side lookup you used JWTs to avoid — but only for the (few) revoked tokens, so it’s cheaper than sessions.
3. Rotate the signing key: invalidates all tokens at once. Nuclear; used for a breach.
🎙️ The balanced position: “JWTs are stateless and scale beautifully, but the trade is that you can’t instantly revoke them. So I’d use short-lived access tokens — say 15 minutes — with revocable server-side refresh tokens. If instant revocation on every request is a hard requirement, sessions are actually the better fit.”
| Sessions | JWT | |
|---|---|---|
| State | Server-side | In the token |
| Revocation | ✅ Instant | ❌ Hard (until expiry) |
| Scaling | Needs shared store | ✅ Stateless |
| Lookup per request | Yes | No |
| Size | Small ID | Larger |
| Multi-region | Store must be reachable | ✅ Easy |
| Best for | Traditional web apps; instant logout matters | APIs, microservices, mobile, SSO |
🚨 The nuanced truth: JWTs aren’t automatically better — the “stateless” appeal is real but the revocation cost is also real. For a monolithic web app where you control everything and want instant logout, sessions are often the simpler, safer choice. For distributed systems, mobile, and cross-service auth, JWTs’ statelessness wins. Reflexively choosing JWTs “because they’re modern” is a weak answer.
🚨 A real, tricky problem that comes up and that many get wrong:
| Storage | XSS risk | CSRF risk | Notes |
|---|---|---|---|
localStorage |
🔴 High — JS reads it, so any XSS steals it | ✅ None | Common but risky |
HttpOnly cookie |
✅ JS can’t read it | 🔴 Needs CSRF protection | Safer against XSS |
| In-memory (JS variable) | Medium (lost on refresh) | ✅ None | Access token in memory, refresh in HttpOnly cookie |
🚨 The recommended pattern: access token in memory (or short-lived), refresh token in an
HttpOnly, Secure, SameSite cookie. localStorage is convenient and exposes your token to any
XSS on the page — which is why security-conscious teams avoid it for auth tokens.
🚨 The most confused topic in this chapter. OAuth 2.0 is not a login protocol — it’s a delegated authorization framework. It answers: “How can app X access my data on service Y, without X knowing my Y password?”
The concrete example: a photo-printing app wants your Google Photos. You don’t give it your Google password. Instead, Google gives the app a scoped access token — “read photos only” — that you can revoke anytime.
The four roles:
The Authorization Code flow (the main one):
sequenceDiagram
participant U as You
participant App as Photo App
participant G as Google Auth
participant API as Google Photos API
U->>App: "Connect Google Photos"
App->>G: redirect to Google, asking for "photos.read" scope
U->>G: log in, consent ("Allow Photo App to read your photos?")
G->>App: redirect back with an authorization CODE
App->>G: exchange code + client secret for an ACCESS TOKEN
G->>App: access token (scoped to photos.read)
App->>API: request photos with the access token
API->>App: photos
🚨 Why the two-step code exchange (code, then token) instead of returning the token directly? Because the code goes through the browser (visible in the redirect), but the token is exchanged server-to-server with the client secret, so the token never touches the browser. Modern flows add PKCE (Proof Key for Code Exchange) so even public clients (mobile, SPAs) that can’t hold a secret are protected against code interception. PKCE is now recommended for all clients — a good detail to mention.
Scopes are the least-privilege mechanism: the token is limited to exactly what was granted
(photos.read, not photos.write or contacts). → Least privilege
🚨 OAuth is for authorization; OIDC adds authentication. “Log in with Google” is OIDC, not raw OAuth — a distinction interviewers check.
OIDC is a thin layer on top of OAuth 2.0 that adds an ID token (a JWT) containing who the user is (their identity claims — name, email, subject ID). OAuth gives you an access token to do things; OIDC gives you an ID token to know who logged in.
OAuth access token: "this app may read your photos" (authorization)
OIDC ID token: "the person who logged in is Bilal,
email b@example.com, verified" (authentication)
🎙️ “‘Log in with Google’ is OpenID Connect — it uses OAuth’s authorization flow but adds an ID token that tells us who the user is. Raw OAuth 2.0 alone is only authorization; people misuse it for login, which is why OIDC was standardized.”
This is why Single Sign-On (SSO) works: one identity provider (Google, Okta, Azure AD) authenticates you, and every app trusts its ID tokens. → AuthN vs AuthZ
| Choice | Gain | Cost |
|---|---|---|
| Sessions | Instant revocation; opaque; simple | Stateful; shared store; lookup per request |
| JWT | Stateless; scales; no lookup | Hard to revoke; larger; browser storage risk |
| Short JWT + refresh token | Statelessness with bounded compromise + revocability | More moving parts; a token flow to build |
| OAuth (delegated) | No password sharing; scoped, revocable access | Complex flow; must be implemented correctly |
| OIDC / SSO | One login for many apps; centralized identity | Dependency on the identity provider |
localStorage without noting the XSS risk.1. Build both auth models. A session-based login (session in Redis, opaque cookie) and a JWT-based one. Then implement “log out everywhere.” Watch how trivial it is with sessions (delete the session) and how genuinely hard it is with JWTs (you can’t). This makes the central trade-off concrete.
2. Decode a JWT. Paste any JWT into jwt.io (or base64-decode the parts). See that the payload is readable by anyone — a JWT is signed, not encrypted, so never put secrets in it. Then tamper with a claim and watch signature verification reject it.
3. Implement the refresh flow. 15-minute access token, long-lived refresh token stored server-side. Confirm a stolen access token stops working after 15 minutes, and that revoking the refresh token prevents new access tokens.
4. Run an OAuth flow. Register an app with Google/GitHub OAuth and implement the authorization code flow. Watch the redirect carry a code, then exchange it server-side for a token. Seeing the code-vs-token separation makes the “why two steps” point clear.