JWT vs Session-Based Auth
The classic interview question. JWTs are popular partly because they’re new and partly because they solve real problems — but they’re not always the right tool. Sessions are simpler and often safer.
For broader stateful-vs-stateless context: Stateful and stateless.
The mechanics, side by side
Session-based
1. Login: POST /login {user, pass} → server validates
2. Server: creates session in Redis: {sess_xyz123 → {user_id: 42, ...}}
sets cookie: Set-Cookie: session=sess_xyz123; HttpOnly; Secure
3. Each request: Cookie: session=sess_xyz123
4. Server: Redis lookup: redis.get("sess:xyz123") → user data
5. Logout: redis.delete("sess:xyz123"); clear cookieThe session ID is an opaque random string. State lives in Redis. Server is stateful (via Redis).
JWT-based
1. Login: POST /login {user, pass} → server validates
2. Server: creates JWT: jwt.encode({sub: 42, exp: ...}, secret)
returns it; client stores
3. Each request: Authorization: Bearer eyJ...
4. Server: jwt.decode(token, key) → payload (signature check, exp check)
no DB lookup
5. Logout: (the hard part — see [05_revocation_logout.md](05_revocation_logout.md))The token IS the state. Server is stateless for verification.
Side-by-side comparison
| Session-based | JWT-based | |
|---|---|---|
| Storage | server (Redis / DB) | client (cookie / localStorage) |
| Verification cost | Redis/DB lookup per request | local crypto verification |
| Revocation | trivial (delete from Redis) | hard (blocklist, short tokens, version) |
| Scaling | shared session store needed | stateless (any server can verify) |
| Cross-service auth | each service hits session store | each service has the public key |
| Token size in transit | small (~30 chars cookie) | larger (~500-1000 bytes) |
| Privacy | server-side (DB only) | claims readable by anyone with token |
| Mid-session updates | trivial (update Redis) | wait for next refresh |
| CSRF | yes (cookies sent automatically) | depends (header-based usually no) |
| XSS impact | session ID via cookie HttpOnly = safer | localStorage = exposed; cookie HttpOnly = safer |
When to choose sessions
- Single domain / monolith: simplicity wins.
- Easy revocation matters: logout, force-logout, ban a user — instant.
- Roles/permissions change mid-session: instant reflection.
- You already have Redis: marginal cost.
- Privacy of identity matters: nothing in the cookie except an opaque ID.
The classic Rails / Django default. Works for most apps. Don’t over-engineer with JWT if sessions cover your needs.
When to choose JWT
- Multiple services verify: microservices, federated systems. Each service has the public key; no shared session store.
- Cross-domain auth: SSO, OIDC (ID tokens are JWTs).
- Serverless / Lambda: instances die between requests; can’t hold session state.
- Massive scale: per-request Redis lookups become a bottleneck (premature optimization for most teams).
- Multi-region active-active: writing sessions to one region’s Redis and reading from another is painful; stateless tokens transit unchanged.
- Native mobile: cookies are awkward; bearer tokens are cleaner.
Note: most of these are “you have specific reasons.” For a typical B2B SaaS in one region with one frontend, sessions are simpler.
The hybrid pattern — best of both
Many production systems combine:
1. User logs in
2. Server: creates session in Redis (server-side state)
3. Server: issues a signed cookie pointing at the session
4. Each request: cookie → Redis lookup → user
5. For service-to-service: the API service issues a short-lived JWT
signed by an internal key, containing { sub, scopes, exp: 5min }
6. Downstream services validate the JWT locallyThe user-facing auth is session-based (easy revocation, no PII in token). Internal service-to-service auth uses JWT (statelessness, no cross-service Redis lookups).
Best of both worlds: simple revocation at the edge, fast verification internally.
The “JWT is stateless” claim — qualified
The pure stateless JWT model:
- No server-side state.
- Pure crypto verification.
- Any server handles any request.
In practice, real JWT auth often has:
- Refresh tokens in DB (for rotation + revocation).
- Access token blocklist for emergency revocation.
- User token-version counter (for “log out everywhere”).
- JWKS cache (per-process state).
By the time you’ve added these for production safety, you’re more “stateless-ish with caches” than truly stateless. The pure-stateless ideal is rare.
This isn’t a knock on JWT — the practical model still scales better than per-request session lookups. Just don’t sell JWT as a silver bullet for statelessness.
XSS vs CSRF — different threats
For browser auth:
| Session cookie | JWT in localStorage | JWT in HttpOnly cookie | |
|---|---|---|---|
| XSS steals the token? | no (HttpOnly) | yes — usable from anywhere | no (HttpOnly) |
| XSS uses it in-page? | yes, while it runs | yes | yes, while it runs |
| CSRF (cross-site cookie send)? | yes — need protection | no (JS adds header) | yes — need protection |
The summary:
- HttpOnly stops theft, not use: an injected script can still send requests that carry the cookie for as long as it runs.
- Session cookies + CSRF tokens: time-tested defense.
- JWT in localStorage: XSS takes the token away. Don’t.
- JWT in HttpOnly cookies: equivalent security to sessions but adds JWT complexity. Storage options in full: Access and Refresh Tokens.
If you’re using JWT in HttpOnly cookies, you’ve reinvented sessions with extra steps. Reflect on whether you need JWT.
Performance comparison (rough)
For a typical API request:
| Session | JWT | |
|---|---|---|
| Cookie parsing | ~10 μs | ~10 μs |
| Token lookup | 200-500 μs (Redis) | 100-200 μs (RSA verify) |
| Per-request DB | 1 (session lookup) | 0 |
For most apps the difference is noise. For very high-RPS services (50k+/sec), the absence of Redis call matters. For everyone else, premature optimization.
Migration paths
If you’re starting fresh: pick session unless you have a specific reason for JWT.
If you have JWT and want sessions:
- Issue server-side sessions on login.
- Tokens become opaque session IDs.
- Add Redis or scale your DB.
If you have sessions and want JWT:
- Add a token-issuing endpoint.
- Refactor middleware to verify tokens.
- Add refresh + rotation.
- Add blocklist for revocation.
Migration in either direction takes weeks of careful work. Don’t switch without a clear win.
Common pitfalls (when picking one)
- JWT for everything because “it’s modern” — you’ve inherited revocation complexity for no real gain.
- Sessions across microservices without a shared store — only works on one server.
- JWT with all the session features bolted on — token-version, blocklist, refresh DB — at that point you have sessions plus parsing overhead.
- Mixing cookie auth and JWT auth on the same domain — confusing CSRF model.
Common interview confusions
- “JWTs replace sessions.” — JWTs solve a specific problem (stateless verification, cross-service). For typical monolithic auth, sessions are simpler. Both coexist in modern systems.
- “Sessions don’t scale.” — Redis handles tens of thousands of ops/sec on cheap hardware. “Doesn’t scale” is rarely a real concern below very high RPS.
- “JWTs are more secure.” — different threat model. An
HttpOnlycookie stops XSS from stealing the session, but not from using it: injected script can still make authenticated requests from the page. JWTs in localStorage add theft on top of that.
Interview angle 7
- “JWT vs session — when each?” — session for single-domain monoliths, when revocation matters, when you can run Redis. JWT for microservices / multi-service verification, OIDC, serverless, true cross-domain SSO. Hybrid is common.
- “What’s the main downside of JWT?” — revocation. Stateless = can’t kick someone out before
exp. Mitigations (blocklist, short tokens, token-version) sacrifice statelessness. Sessions revoke trivially. - “How do JWTs scale better than sessions?” — no per-request session-store lookup; each server verifies locally with the public key. For very high-RPS or cross-service auth this matters; for typical apps it’s premature optimization.
- “Can sessions work across microservices?” — yes with a shared session store (all services read from the same Redis). Adds latency per request and a SPOF. For service-to-service auth, JWT is usually cleaner.
- “Why might storing JWT in localStorage be a bad idea?” — XSS-vulnerable. Any injected JS reads localStorage → full session compromise. HttpOnly cookies are JS-inaccessible. For SPAs: refresh token in HttpOnly cookie, access token in memory.
- “You inherit a JWT-based app with no revocation. What do you change?” — short access token lifetime (5-15 min) immediately. Refresh token rotation + DB-backed revocation. Optionally a Redis blocklist for emergency revocation of specific tokens. Token-version in claims for “log out everywhere.”
- “In a hybrid system, where does JWT fit?” — user-facing auth uses sessions (easy revocation, privacy). Internal service-to-service auth uses short-lived JWTs (statelessness, fast verification, no cross-service Redis lookups).