"Use JWT, it's stateless" was the consensus advice for a decade. Then the security community spent the same decade documenting the ways JWT goes wrong. Both have valid uses. Here's the actual decision framework.
How they work, in one paragraph each
Session cookies: on login, the server generates a random session ID, stores it (Redis, DB) with the user's info, and sets it in a cookie. Each request, the server looks up the session ID and finds the user. Logout deletes the row.
JWT (JSON Web Tokens): on login, the server creates a JSON object with the user's info, signs it cryptographically, and returns it. The client sends it back on every request. The server verifies the signature and trusts the contents. There's nothing stored server-side. Decode any JWT in our JWT decoder to see the structure.
The five real differences
1. Revocation
Session cookies revoke instantly: delete the row, the session is dead. JWT is the opposite: a signed token is valid until it expires, period. If a user's account is compromised, you can't kill their existing tokens without adding state — which defeats the "stateless" benefit.
Workarounds: short expiry (5-15 minutes) plus refresh tokens, or a blocklist of revoked JWT IDs. Both reintroduce server-side state. If revocation matters to you, sessions are simpler.
2. Scaling
The classic JWT pitch: "no shared session store means horizontal scaling is trivial." This is genuinely true. If your backend is many stateless services, JWT means each one can verify a token alone, given the public key. No Redis hop, no DB hit.
But: modern session stores (Redis with short TTLs, or signed cookies that include the user ID with a tiny DB lookup) handle this fine at most scales. JWT's scaling advantage shows up at very high throughput — Twitter, Stripe-scale — not at hundred-request-per-second SaaS.
3. Token size
Session ID: 32 bytes. JWT: 200-2000 bytes, sent with every request. If your auth header is 1 KB and you make 100 API calls loading a page, that's 100 KB of redundant payload. Not huge, but real on cellular.
4. Where the token lives in the browser
Session cookies use HttpOnly cookies — JavaScript can't read them, which protects against XSS stealing the session. JWT is usually stored in localStorage or sessionStorage, which any script on the page can read. An XSS vulnerability anywhere on your origin = JWT exfiltration.
You can put JWT in HttpOnly cookies too — at which point you've inherited cookies' CSRF problem and lost the "send it in the Authorization header from JS" mental model. Most teams that pick JWT end up with localStorage and XSS risk.
5. Auditing
With session cookies, you can list every active session for a user from the database. "Sign out of all devices" is a SQL DELETE. With JWT, there's no list — you'd have to log every issuance and intersect with not-yet-expired tokens, which is more state than just using sessions.
So when does JWT win?
Federated authentication. When the issuer and the verifier are different services or organizations. The whole point of OpenID Connect is that an identity provider issues a JWT that any service can verify with the IdP's public key. This is a real, decisive JWT advantage.
Service-to-service auth. Microservices accepting requests from other microservices. Each service verifies the JWT independently; no central session lookup. The short-token-lifetime concerns don't apply because these tokens live for minutes.
Mobile and SPA where cookie semantics are awkward. A native mobile app doesn't naturally fit cookies. A token returned as a string and sent in the Authorization header is simpler.
Stateless edge functions. Cloudflare Workers, Lambda@Edge — code that needs to know who the user is without making a database call. JWT verification is microseconds; a DB call across a region is milliseconds.
When sessions win
Traditional web apps. A monolith serving its own HTML to a logged-in user. The complexity of JWT brings no benefit; sessions are the standard.
Anywhere you need revocation. Banking, healthcare, anything with compliance requirements about session management.
Single-team apps. If one team owns both the issuer and the verifier and they're in the same network, JWT is solving a problem you don't have.
The hybrid pattern (used by most large apps)
This is what real systems usually do:
1. User logs in → server returns a short-lived JWT (15 min) plus a refresh token stored as an HttpOnly cookie.
2. Client uses the JWT for API requests. Service verifies it without DB lookup.
3. When the JWT expires, client hits /refresh with the refresh-token cookie. Server checks the refresh token against its database, issues a new JWT.
4. On logout: server invalidates the refresh token row. The JWT remains valid for up to 15 minutes — accepted risk.
This gives you most of JWT's scaling benefit (verification doesn't hit the DB) while preserving most of sessions' revocation property (within 15 minutes).
Common JWT mistakes
The bugs that keep showing up: accepting alg: none, confusing HS256 (symmetric) and RS256 (asymmetric) and accepting the public key as an HMAC secret, storing too much in the token (passwords, refresh tokens), trusting kid headers to load arbitrary keys. See our deeper JWT security guide.
Wrap-up
If you're building a single-app monolith with a single user database: sessions. If you're building federated identity across multiple services or organizations: JWT. If you're building a SPA or mobile API: the hybrid pattern. The default for new projects shouldn't be either format — it should be the one that matches your topology.