JWT vs Session Cookies — The Real Security Tradeoffs (Not the Blog-Bro Version)
Why This Debate Exists
Search “JWT vs session” and you’ll find two camps:
- “JWTs are stateless and scalable, use them everywhere”
- “JWTs are dangerous, sessions are always better”
Both are wrong — or at least incomplete. The correct answer depends on your threat model and your architecture. This article gives you the mechanics to make that decision yourself.
How Sessions Actually Work
A session is a server-side record:
Client Server
│ │
│── POST /login ───────────────>│
│ │ 1. Verify credentials
│ │ 2. Create session: { userId: 42, role: 'student' }
│ │ 3. Store in DB/Redis: sessions["abc123xyz"] = {...}
│<── Set-Cookie: sid=abc123xyz ─│
│ │
│── GET /dashboard ────────────>│ Cookie: sid=abc123xyz
│ │ 4. Lookup sessions["abc123xyz"] → { userId: 42 }
│ │ 5. Authorize request
│<── 200 OK ────────────────────│
The session ID itself is meaningless — it’s just a random string. The real data lives on the server. The cookie is only a pointer.
Revocation is trivial
DELETE sessions["abc123xyz"]
User is immediately logged out everywhere. No tokens to invalidate, no grace periods.
How JWTs Actually Work
A JWT (JSON Web Token) encodes the claims inside the token itself:
eyJhbGciOiJIUzI1NiJ9 ← Header: { alg: "HS256" }
.
eyJ1c2VySWQiOjQyLCJyb2xlIjoic3R1ZGVudCIsImV4cCI6MTc1MzQwMDAwMH0
← Payload: { userId: 42, role: "student", exp: 1753400000 }
.
HMAC_SHA256(base64(header) + "." + base64(payload), SECRET_KEY)
← Signature
The server never stores the token. On each request:
- Decode the header + payload (base64, no secret needed)
- Verify the signature using the secret key
- Check
exphasn’t passed - Trust the claims
No database lookup. This is why people call JWTs “stateless.”
The Real Tradeoffs
Revocation
| Sessions | JWTs | |
|---|---|---|
| Revoke a specific user | DELETE sessions[id] — instant | ❌ No built-in mechanism |
| Logout on password change | Invalidate all user sessions | JWT stays valid until exp |
| “Log out all devices” | Clear all sessions for userId | Need a denylist (→ now stateful) |
This is the biggest practical problem with JWTs. If a user changes their password or your account is compromised, a signed JWT is still valid until it expires. If your exp is 7 days, an attacker has a 7-day window even after you lock them out.
The common workaround is a JWT denylist stored in Redis. But now you’re doing a database lookup on every request — you’ve lost the stateless property you chose JWTs for.
Storage
| Where to store | XSS risk | CSRF risk | |
|---|---|---|---|
| Session cookie | HttpOnly; Secure; SameSite=Strict cookie | ✅ None (JS can’t read HttpOnly) | ⚠️ Needs CSRF token if SameSite=None |
JWT in localStorage | localStorage | ❌ Any XSS reads it | ✅ Not sent automatically |
JWT in HttpOnly cookie | Cookie | ✅ None | ⚠️ Same as sessions |
Storing JWTs in localStorage is the default in most tutorials and it’s the wrong default. Any JavaScript running on your page (including injected scripts from a dependency vulnerability) can read localStorage and steal the token.
JWT in an HttpOnly cookie gives you the same XSS protection as sessions, but you still have the revocation problem.
Scalability
This is the argument usually made for JWTs:
“Sessions require a centralized store (Redis). JWTs work across multiple servers without shared state.”
This was more true in 2015. Today:
- Redis is cheap and fast (sub-millisecond lookups)
- Managed Redis (AWS ElastiCache, Redis Cloud) is trivially scalable
- The difference matters only at extreme scale (millions of RPS)
For a university project, a startup, or anything up to ~100k DAU, session storage in Redis is not your bottleneck. Don’t accept added security complexity to solve a performance problem you don’t have.
When JWTs Are Actually the Right Choice
JWTs shine in service-to-service authentication:
Service A Service B
│ │
│── GET /api/data ────────────>│
│ Authorization: Bearer JWT │
│ │ Verify JWT signature
│ │ No DB call — Service B doesn't have access
│ │ to Service A's session store
│<── 200 OK ───────────────────│
In microservices or API-first architectures where services need to trust claims from another service without shared state, JWTs make sense. This is their actual use case.
JWTs also work well for short-lived one-time tokens: email verification links, password reset links, magic links. An email verification JWT that expires in 15 minutes doesn’t need a revocation mechanism because the window is too short for it to matter.
Practical Recommendation for Student Projects
| Scenario | Use |
|---|---|
| Web app with traditional login | Sessions + HttpOnly cookie |
| API consumed by mobile apps | JWT in Authorization header (short expiry + refresh token pattern) |
| Microservices auth | JWT (service-to-service, short expiry) |
| Email verification / password reset | Short-lived JWT (15–60 min) |
| “Log out all devices” is a requirement | Sessions (or JWT + denylist) |
The Correct Node.js Session Setup
import session from 'express-session';
import RedisStore from 'connect-redis';
import { createClient } from 'redis';
const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET, // long random string, never in code
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true, // JS cannot read this cookie
secure: true, // HTTPS only
sameSite: 'strict', // blocks CSRF from cross-origin requests
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
},
}));
This setup is:
- Immune to XSS (HttpOnly)
- Immune to CSRF (SameSite=Strict)
- Revocable instantly (delete from Redis)
- Persistent across server restarts (Redis store)
Summary
JWTs are not inherently bad. Sessions are not inherently old-fashioned. The question is:
- Do you need immediate revocation? → Sessions
- Do you have cross-service auth without shared state? → JWT
- Are you storing JWT in localStorage? → Stop. Use HttpOnly cookie or sessions instead.
The “stateless scalability” argument for JWTs is mostly irrelevant for applications under 100k DAU. Don’t trade revocation capability for a performance benefit you haven’t measured.
Found this useful? Explore real student projects on CodexCampus.
Explore the Vault →