#web #security #auth #backend #nodejs

JWT vs Session Cookies — The Real Security Tradeoffs (Not the Blog-Bro Version)

· 10 min read

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:

  1. Decode the header + payload (base64, no secret needed)
  2. Verify the signature using the secret key
  3. Check exp hasn’t passed
  4. Trust the claims

No database lookup. This is why people call JWTs “stateless.”


The Real Tradeoffs

Revocation

SessionsJWTs
Revoke a specific userDELETE sessions[id] — instant❌ No built-in mechanism
Logout on password changeInvalidate all user sessionsJWT stays valid until exp
“Log out all devices”Clear all sessions for userIdNeed 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 storeXSS riskCSRF risk
Session cookieHttpOnly; Secure; SameSite=Strict cookie✅ None (JS can’t read HttpOnly)⚠️ Needs CSRF token if SameSite=None
JWT in localStoragelocalStorage❌ Any XSS reads it✅ Not sent automatically
JWT in HttpOnly cookieCookie✅ 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

ScenarioUse
Web app with traditional loginSessions + HttpOnly cookie
API consumed by mobile appsJWT in Authorization header (short expiry + refresh token pattern)
Microservices authJWT (service-to-service, short expiry)
Email verification / password resetShort-lived JWT (15–60 min)
“Log out all devices” is a requirementSessions (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 →