system-design

Sessions, JWTs, and OAuth 2.0 / OIDC

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


The problem

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:

  1. Sessions — the server remembers you (state on the server, an opaque ID with the client).
  2. Tokens (JWT) — the proof is self-contained (state in the token, nothing on the server).

The difference comes down to one question: where does the state live, and can you revoke it instantly?


Session-based authentication

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:


Token-based authentication (JWT)

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).


The JWT revocation problem, honestly

🚨 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 vs JWT: choosing

  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.


Where to store tokens in a browser

🚨 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.


OAuth 2.0: delegated authorization

🚨 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


OpenID Connect (OIDC): login built on OAuth

🚨 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


⚖️ Trade-offs

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

In the real world


🚨 Interview traps


🎙️ Soundbites


🛠️ Try it

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.


Check yourself

1. What's the fundamental trade-off between sessions and JWTs? Where the state lives, which determines revocability. **Sessions** store auth state server-side (an opaque ID with the client), so you can delete the session to log the user out *instantly and everywhere* — but the server is stateful, needs a shared session store reachable by every app server, and does a lookup per request. **JWTs** put the state in a signed, self-contained token, so any server verifies it with no lookup and no shared store — trivially stateless and scalable — but a signed token is valid until it expires, so you *cannot instantly revoke it*. Sessions trade scalability for instant control; JWTs trade instant control for scalability. Neither is universally better.
2. Why is revoking a JWT hard, and what's the standard mitigation? Because a JWT is stateless and self-verifying — there's no server-side record to delete, and any server that checks the signature will accept it until the `exp` claim passes. So you can't instantly log someone out, kill a stolen token, or ban a user mid-session. The standard mitigation is **short-lived access tokens plus revocable refresh tokens**: the access token (used on every request) is a stateless JWT valid for only 5–15 minutes, so a stolen one is useful briefly; obtaining a *new* access token requires a long-lived refresh token that *is* stored server-side and *can* be revoked. So the fast path stays stateless while the ability to revoke lives in the refresh-token store, and any compromise is bounded by the short access-token lifetime.
3. Why shouldn't you store JWTs in localStorage? Because localStorage is readable by JavaScript, so any cross-site scripting (XSS) vulnerability anywhere on the page — including in a third-party script or dependency — lets an attacker read the token and impersonate the user. Cookies with the `HttpOnly` flag are invisible to JavaScript, so an XSS can't steal them (though they need CSRF protection via `SameSite`). The recommended pattern is to keep the access token in memory (a JavaScript variable, lost on refresh, minimizing exposure) and the refresh token in an `HttpOnly`, `Secure`, `SameSite` cookie. localStorage is popular because it's convenient and survives refreshes, but that convenience is exactly the XSS exposure.
4. Why is OAuth 2.0 not a login protocol, and what is OIDC? OAuth 2.0 is a **delegated authorization** framework: it lets an application access a user's data on another service (their Google Photos, their GitHub repos) without the application ever seeing the user's password, via a scoped, revocable access token. It answers "what may this app do on my behalf," not "who is this user." People misused it for login by treating "the app got an access token" as "the user is authenticated," which is insecure and doesn't reliably tell you *who* logged in. **OpenID Connect (OIDC)** is a thin standard layer on top of OAuth that adds authentication: alongside the access token, it issues an **ID token** (a JWT) containing verified identity claims — who the user is, their email, a stable subject ID. "Log in with Google" is OIDC. OAuth authorizes; OIDC authenticates.
5. In the OAuth authorization code flow, why exchange a code for a token instead of returning the token directly? To keep the access token out of the browser. The authorization *code* is returned via a browser redirect, where it's visible in the URL, referrer headers, and browser history — but a code is useless alone. The client then exchanges it for the actual access token in a direct, back-channel, server-to-server request that includes the client secret. So the powerful, long-lived access token never travels through the browser or appears in any URL, and an attacker who intercepts the redirect gets only a code they can't redeem without the secret. For public clients (mobile apps, SPAs) that can't safely hold a secret, **PKCE** adds a per-request proof so an intercepted code still can't be exchanged by an attacker — which is why PKCE is now recommended for all clients.

Further reading