JWT vs Sessions
Ask which one to use and you’ll get two confident camps yelling past each other. Most of the disagreement comes from people comparing them without first being clear on what each one actually is, so let’s do that part properly and let the comparison fall out of it.
The problem both of them solve
HTTP has no memory.
Each request arrives at the server as a total stranger. You typed your password thirty seconds ago, but this new request for /dashboard carries none of that with it. The server has no idea the two things are related.
So the browser needs some way to say “remember me, I logged in already.” Sessions and JWT are two different answers.
Sessions
A session is a note the server keeps about you.
You log in. The server checks your password, and once it’s happy, it writes down something like:
session_id: a8f3k29xz
user_id: 412
role: admin
logged_in_at: 10:42am
That lives somewhere the server controls: memory, Redis, a database table. Then it hands you back exactly one thing, the session ID, in a cookie:
Set-Cookie: session_id=a8f3k29xz; HttpOnly; Secure
Your browser sends that cookie automatically on every request after that. The server sees a8f3k29xz, looks it up, finds user 412, and lets you in.
The part worth sitting with: that cookie contains no information about you. It’s a random string. If someone reads it, they learn nothing. It’s a coat check ticket. The ticket isn’t your coat, it points to where your coat is hanging, and the server owns the coat.
JWT
A JWT flips it. Instead of the server keeping a note about you, the server hands you the note and trusts you to carry it back.
You log in, the server verifies your password, then builds a small package:
{
"sub": "412",
"role": "admin",
"exp": 1731859200
}
It signs that with a secret only the server knows, and gives you the whole thing:
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI0MTIiLCJyb2xlIjoiYWRtaW4ifQ.4Kt9_signature
Three chunks separated by dots: header, payload, signature.
On each request you send it back, and the server doesn’t look anything up. It recomputes the signature with its secret and checks it matches. If it does, the token is genuine and unmodified, so the server reads user_id: 412 straight out of it and moves on.
No storage. No lookup. That’s the entire pitch.
The thing everybody gets wrong
A JWT is signed, not encrypted.
That payload is plain base64. Not encryption, just encoding. Anyone holding the token can paste it into jwt.io and read every field. Go do that with one of your own tokens right now, it’s a useful little shock.
The signature proves nobody changed the contents. It does nothing to hide them. Never put a password, an API key, or anything private in a payload.
Where they diverge
Lookups. Every session request hits Redis or your database. Fast, but it’s a hit. JWT verification is math against a secret, so no network call.
Scaling. One server, sessions are trivial. Twelve servers, they all need to see the same session store, so now you’re running Redis and keeping it up. A JWT works on any instance that knows the secret.
Revocation. This is the one that matters.
Delete a session row and that user is out. Immediately. Banned a spammer, killed a stolen laptop’s access, forced a logout after a password change: all one delete.
A JWT stays valid until it expires. There’s no record to delete. A token with a seven-day expiry that leaks on Monday belongs to whoever has it until next Monday. You can keep a blocklist of revoked tokens, but then you’re querying a database on every request, which is the exact thing you picked JWT to avoid. You’ve rebuilt sessions with extra steps.
Size. Session ID: ~32 characters. JWT: several hundred bytes, on every request.
What I’d actually use
For a normal web app with a browser frontend, sessions. Simpler, battle-tested, already built into your framework, and instant logout is worth more day to day than saving a Redis call. Far fewer apps genuinely can’t afford that lookup than the internet implies.
JWT when there’s a real reason: several services that each verify identity without calling a central auth server, third-party API clients, mobile apps where cookies are a pain.
Most serious systems land in the middle anyway. Short-lived access token, maybe fifteen minutes, plus a long-lived refresh token that is stored server-side. Fast stateless verification most of the time, and when you need to cut someone off you kill the refresh token and they’re locked out within fifteen minutes. You give up perfect statelessness and get a working off switch.
Which, honestly, is the trade I’d make almost every time.