JWT vs session cookies — how to actually choose
Every team building auth has this argument. Someone says "let's use JWTs, they're modern." Someone else says "sessions are simpler." A third person opens a blog post from 2016, and the meeting dissolves into a religious war with strong opinions and no whiteboard.
Here's the thing: both work. This isn't a right-versus-wrong question — it's a question of where you keep your state, and what you're willing to give up to keep it there. Once you see the actual tradeoff, the argument ends in about two minutes. Let's end it.
- How sessions actually work — and why the cookie is just a pointer
- How JWTs actually work — and what "stateless" really means
- The one tradeoff everything reduces to: server state vs. revocability
- The revocation problem, and the refresh-token pattern that papers over it
- A two-minute decision checklist you can run on your own project
How sessions actually work
With session-based auth, the state lives on the server. The client only ever holds an opaque reference — a session ID — that means nothing on its own.
# 1 · login
POST /login { email, password }
# 2 · server creates a session record (memory / Redis / DB)
sessions.set("abc123…", { userId: 42, role: "admin" })
# 3 · server sends back an opaque pointer
Set-Cookie: sid=abc123…; HttpOnly; Secure; SameSite=Lax
# 4 · every request after, the browser sends the pointer back
Cookie: sid=abc123…
# 5 · server looks it up — the cookie itself proves nothing
user = sessions.get("abc123…")
The crucial detail is step 5: the server looks the session up. The cookie is a claim ticket, not an identity. All the real information — who you are, what you can do — sits in the server's session store, and the server consults it fresh on every single request.
How JWTs actually work
With JWT-based auth, the state lives in the token itself. The server signs a bundle of claims and hands it to the client; it keeps no record of having done so.
# 1 · login
POST /login { email, password }
# 2 · server builds + SIGNS a token (no record kept)
jwt.sign({ sub: 42, role: "admin", exp: 1785459600 }, SECRET)
# 3 · client stores it and sends it on every request
Authorization: Bearer eyJhbGciOiJIUzI1NiIs…
# 4 · server verifies signature + expiry, then trusts the claims
jwt.verify(token, SECRET) → { sub: 42, role: "admin", … }
The server never looks anything up. It checks the signature — proof the
token is genuine and unmodified — checks the exp claim, and
trusts the rest. That's what "stateless" means: any server holding
the key can verify the token, with no shared database or session
store in the loop. If you want to see exactly what's inside one, the
JWT decoder shows you the header and
claims in one paste — and the
JWT post covers which claims to check.
The one tradeoff everything reduces to
Strip away the blog-post noise and the whole debate is a single line:
sessions → state on the server → easy to revoke, harder to scale
JWTs → state in the token → easy to scale, hard to revoke
Everything else anyone argues about — microservices, mobile apps, logout buttons, Redis, sticky sessions — is a consequence of that one line. Server state gives you control; statelessness gives you portability. You're choosing which problem you'd rather have.
Where sessions clearly win
- Revocation is trivial. Log out, "log out everywhere," ban a user, invalidate a leaked session — delete the record and it's done, effective on the very next request.
- Changes apply immediately. Change a user's role or permissions in the database, and the next request sees it — because the server re-reads the session every time.
- Less data in the wild. The cookie is an opaque string. Nothing about the user travels to the client, so there's nothing to leak or decode.
- Smaller requests. A 32-character session ID is far lighter than a JWT riding along on every request.
- Simpler mental model. One store, one lookup, one source of truth. For a monolith with a single database, this is the boring, correct default.
Where JWTs clearly win
- Scaling is trivial. No shared session store, no sticky sessions. Any instance can verify any token, so you add servers without coordinating state.
- Cross-service auth. In microservices, every service can verify the same token with the public key — no central session lookup on every hop.
- Mobile and native apps. Cookies are awkward on mobile; a
Bearertoken in an Authorization header is natural. - Cross-domain by design. A token works anywhere you can set a header, sidestepping cookie domain and SameSite gymnastics.
- Federation. OAuth 2.0 and OpenID Connect speak JWT natively — if you're integrating with Google, GitHub, or an identity provider, you're getting JWTs whether you like it or not.
The revocation problem (and the honest fix)
Here's the JWT weakness in one sentence: a signed JWT is valid
until it expires, and there is no server-side off switch. You can't
"log out" a token. If it leaks, it works until exp. If you ban
a user, their token keeps working. That's not a bug you patch — it's a
consequence of statelessness.
The industry's honest answer is the short-lived access token + refresh token pattern:
access token short-lived (5–15 min) · stateless · sent on every request
refresh token long-lived (days/weeks) · stored SERVER-SIDE · revocable
# "log out" / "revoke" = delete the refresh token server-side
# the access token then dies on its own within minutes
Notice the irony: "stateless" JWT auth almost always reintroduces state — in the refresh-token store. The statelessness lives in the short-lived access token; the revocability lives in the long-lived refresh token. You're not choosing stateless-or-not, you're choosing how much state and where.
Never put secrets or sensitive data in a JWT — it's
signed, not encrypted; anyone who intercepts it can read
every claim. And store tokens in an HttpOnly cookie wherever
you can — localStorage is readable by any JavaScript on the
page, which makes it an XSS magnet.
The two-minute decision checklist
Run these in order and stop at the first "yes":
- Are you using OAuth / OpenID Connect? → You're using JWTs. Debate over.
- Do multiple services or a mobile app need to verify auth? → JWTs. The portability is the whole point.
- Do you need instant revocation — "log out everywhere," bans, live permission changes? → Sessions, or JWTs with the refresh-token pattern.
- Is it a monolith with one database and server-rendered pages? → Sessions. Simpler, safer by default, and scaling concerns are a future-you problem.
For a straightforward web app, sessions are the right answer more often than the blogosphere admits. JWTs earn their keep when you need statelessness across services — not because they're newer. Reach for complexity when the architecture demands it, not before.
And whichever you pick, the failure modes to study are well documented — the OWASP session management cheat sheet and RFC 7519 (the JWT spec) are the two references worth bookmarking.
Wrap-up
Sessions keep state on the server — easy to revoke, harder to scale. JWTs keep state in the token — easy to scale, hard to revoke. Everything else is a consequence of that line. Monoliths and server-rendered apps should default to sessions; distributed services, mobile clients, and federated auth earn JWTs. And "stateless" auth still keeps state — just in a refresh-token store you control.
Whichever you choose, you'll spend your debugging time in the same places: expired tokens, wrong audiences, and requests that never carried the credential at all. The JWT decoder handles the first two in one paste, and when the token's fine but the request still fails, that's usually a 401-vs-403 story, not an auth-design one.