JWT vs session cookies
Session cookie: random opaque ID, server holds the truth, revoke instantly by deleting the session. JWT: signed token carries claims, stateless — server doesn't need to look anything up. Sessions win for typical web apps (revocation, security, simplicity). JWTs win for microservice-to-microservice auth and short-lived access tokens paired with refresh tokens. Storing JWTs in localStorage is the most common XSS footgun.
Both are bearer tokens for proving "this request is from user X." They differ in where the truth lives and how they're carried.
Session cookies.
Client → Server: Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Lax
Server's DB: sessions[abc123] = { userId: 7, expires: ... }The cookie value is a meaningless random ID. The server looks it up in a session store (Redis, Postgres) on each request. Revoke = delete the row.
JWT.
Client receives: eyJhbGc...iJIUzI1NiJ9.eyJzdWIiOjcsImV4cCI6...}.signature
header claims (incl. userId, exp) HMAC/RSA sigThe token contains the claims (userId, expiry, scopes). The server verifies the signature with a secret/public key. No DB lookup required — the token is self-describing.
The trade-off table.
| Session | JWT | |
|---|---|---|
| Revoke instantly | yes | no (need blocklist) |
| Stateless server | no | yes |
| Cross-domain | hard (cookie scope) | easy (Authorization header) |
| Carries data | no | yes |
| Storage | HttpOnly cookie | Cookie or header |
| Replay window after compromise | until you delete session | until exp |
| Size on the wire | ~40 bytes | 200–1000+ bytes |
| Need a session store | yes | no (until you add blocklist) |
The 2026 default for browser apps: HttpOnly cookies + short-lived JWT access tokens + refresh tokens.
Login: server sets HttpOnly cookie containing refresh token (long lifetime)
+ returns short-lived access token (15 min, JWT) — or sets it as another cookie
Request: send access token (cookie or Authorization header)
Refresh: when access token expires, hit /refresh with the refresh cookie
→ server validates refresh token (DB-backed) → issues new access tokenThis combines the best of both: stateless validation per request (no DB hit), revocation via the refresh side (short access tokens limit damage if a refresh is compromised).
Where to store tokens in the browser.
- HttpOnly Secure SameSite cookie — JS can't read it. XSS can't steal it. CSRF is the threat — mitigate with
SameSite=StrictorLax+ a CSRF token for state-changing requests. This is the right default. - localStorage — readable by any JS on the origin. An XSS gets the token. Avoid for auth tokens.
- In memory (variable) — survives until reload. Forces re-login each tab/refresh; some SaaS apps use this with silent SSO refresh.
The interview tell: anyone who says "I store JWT in localStorage" has not been bitten by an XSS incident.
JWT pitfalls people miss.
- No revocation by default. Once issued, a JWT is valid until
exp. If user changes password, you can't invalidate active tokens unless you keep a blocklist (which removes the stateless advantage).
alg: nonebug. Old JWT libraries accepted{"alg":"none"}headers and skipped signature verification. Always pin the algorithm server-side:verify(token, key, { algorithms: ["HS256"] }).
- Public-key confusion. RS256 JWTs verified with the wrong key handler — accepting "HS256" with the public key as the HMAC secret. CVE'd many libraries; use a maintained one.
- Don't put sensitive data in claims. JWT is signed but not encrypted by default. Anyone with the token can decode it. Don't put PII or anything you wouldn't email.
- Clock skew. Verify
expandnbfwith a few seconds of tolerance.
- Refresh token rotation. Each refresh should issue a new refresh token AND invalidate the old. Detect reuse — it usually means a stolen token.
When pure-session is fine.
Most CRUD web apps. Single domain. Few microservices. Sessions are simpler, safer, and the storage cost is trivial. Don't introduce JWTs for the resume bullet.
When JWT shines.
- Service-to-service: a microservice receiving a token from another service can verify it locally without an auth-service round-trip.
- Federated identity / SSO — OAuth's id_token is a JWT for good reason.
- Edge / serverless functions where there's no shared session store.
Senior framing. The interviewer is checking whether the candidate has gotten burned. The right answer in 2026: HttpOnly cookies are non-negotiable; whether the cookie's value is a session ID or a short-lived JWT is a backend implementation detail. Use refresh tokens for revocation. Never put tokens in localStorage. Pin the JWT algorithm.
Follow-up questions
- •How would you handle revocation with JWTs?
- •What is the `alg: none` attack and how do modern libraries prevent it?
- •Why is `HttpOnly` the most important cookie flag for auth?
- •Refresh token rotation — how does it detect reuse?
Common mistakes
- •Storing JWT in localStorage.
- •Not pinning the algorithm on the verifier.
- •Treating JWT exp + signature as 'logged out' — without a blocklist, password change doesn't invalidate.
- •Sending the same long-lived token to every microservice.
Performance considerations
- •JWT saves a DB lookup per request — meaningful at high throughput.
- •JWTs are 200–1000+ bytes — add up on header-heavy APIs.
Edge cases
- •Mobile native apps — keystore / Keychain, not cookies.
- •WebSocket auth — can't send headers after connection; pass token in query (logged!) or via subprotocol.
- •Cross-origin SPA → API — `SameSite=None; Secure; Partitioned` cookies and CORS credentials.
Real-world examples
- •Auth0, Clerk, Supabase Auth — all default to access JWT + refresh + HttpOnly cookie.
- •Rails / Django session cookies — still the default for monolithic apps.