Sessions vs JWT: which one, and why
One is a pointer, the other is a copy. Almost every argument about them comes from missing that one line.
Search this question and you’ll get a hundred answers that all start with “it depends” and never say on what. It depends on one thing, and you can decide it in a sentence.
The actual difference
A session cookie is a pointer: a meaningless id that your server exchanges for state it holds.
A JWT is a copy: the state itself, signed, handed to the client to carry around.
Everything else — scaling, logout, token size, refresh tokens, the whole argument — follows from that one line. Hold onto it; the rest of this post is just consequences.
What a session actually is
The user logs in. Your server writes a row somewhere and hands back an id:
Set-Cookie: sid=9f2c1b8e4a7d; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=1209600
On every later request the browser sends that id back, and your server looks it up:
sid=9f2c1b8e4a7d → { user_id: 42, role: "admin", logged_in_at: ... }
The cookie is worth nothing on its own. It’s a coat check ticket. Steal it and you can use it — but only until the server deletes the row.
The flags are not optional
Most “sessions are insecure” takes are really “someone set the cookie wrong”.
| Flag | What it stops |
|---|---|
HttpOnly | JavaScript reading the cookie — kills the easiest XSS payoff |
Secure | The cookie ever travelling over plain HTTP |
SameSite=Lax | Most CSRF, by not sending it on cross-site POSTs |
Max-Age / Expires | A ticket that lives forever |
Set those four and a session cookie is a genuinely solid default.
What a JWT actually is
Three base64 chunks joined by dots: header, payload, signature.
{
"sub": "42",
"role": "admin",
"iat": 1735689600,
"exp": 1735693200
}
The signature proves the payload came from you and hasn’t been edited. That is the only thing it proves.
What signing does not do
Two things people get wrong constantly:
- A JWT is not encrypted. Base64 is not encryption. Anyone holding the token can read every claim in it. Paste one into a decoder and you’ll see your own payload in plaintext. So: no email addresses you didn’t mean to leak, no internal ids you’d rather not publish, nothing you’d call a secret.
- A valid signature does not mean “still true”. It means “I said this at
iat”. If you demoted that admin thirty seconds ago, the token still saysadminand it still verifies. It will keep sayingadminuntilexp.
That second one is the whole problem.
What each one costs you
| Session | JWT | |
|---|---|---|
| Where state lives | Your store | The token |
| Reading it per request | A store lookup | A signature check |
| Revoke one login | Delete the row | You can’t, until exp |
| Change a role | Next request sees it | Next token sees it |
| Size on every request | ~30 bytes | ~400–1000 bytes |
| Works across domains | Awkward | Easy |
| Extra infrastructure | A store | None |
Read that table as one trade: JWT buys you “no lookup” and pays for it with “no undo”.
Logout is the whole argument
Ask yourself one question:
How do I log this user out of every device, right now?
With sessions: DELETE FROM sessions WHERE user_id = 42. Done, and it takes effect on
the very next request.
With JWTs: you can’t. The token is already in the wild, it verifies, and your server has nothing to check it against. Your options are all bad in a specific way:
- Short expiry — say 5 minutes. You’ve reduced the damage window, not closed it. And now every client needs refresh logic.
- A denylist of revoked tokens — which is a lookup on every request. That’s a session, with extra steps and worse ergonomics.
- A
token_versionon the user, checked per request — also a lookup. Also a session.
Notice the pattern: every way to make a JWT revocable turns it back into a session. That isn’t an argument against JWTs. It’s the price tag, and it should be visible before you pick.
The refresh token pattern, honestly
The standard fix is two tokens: a short-lived access token (stateless, 5–15 minutes) and a long-lived refresh token (stored server-side, revocable).
It works, and it’s what most real systems do. But be clear about what you built: the refresh token is a session. You now have a session and a stateless token, two expiries, a refresh race to handle when three tabs expire at once, and a rotation policy. That complexity is worth it at a certain scale. Below that scale it’s cost with no return.
Three mistakes I see constantly
- Putting a JWT in
localStorage. Now any XSS reads your token directly. A cookie withHttpOnlycan’t be read by script. You can absolutely put a JWT in anHttpOnlycookie — and if you do, ask yourself what the JWT is still buying you. - Long expiry “for convenience”. A 30-day access token is a 30-day window in which a stolen token works and you cannot stop it.
- Trusting claims you didn’t verify. Decoding is not verifying. If your code path
reads
payload.rolebefore checking the signature andexp, you have no auth at all — just a suggestion from the client.
So which one?
Default to sessions. They’re simpler, revocable, smaller on the wire, and the failure modes are well understood. “It doesn’t scale” is folklore; a session lookup is a single indexed read, and you’ll hit a dozen other limits first.
Reach for JWTs when you have an actual reason:
- Several separate services need to verify a caller without sharing one auth database.
- The caller is a machine, on a short-lived credential, where “log it out right now” isn’t a scenario.
- You’re doing a cross-domain or third-party flow where cookies genuinely don’t reach.
If none of those describe you, a JWT is a lookup you removed from a request that was never slow, in exchange for a logout button that doesn’t work.
What to say when you’re asked
Short version, and it’s the honest one:
A session is a pointer to server state; a JWT is a signed copy of that state. Sessions cost a lookup and give you instant revocation. JWTs skip the lookup and give up revocation until expiry. I’d default to sessions, and reach for JWTs when several services need to verify a caller independently — accepting short expiry plus a refresh token, which is a session again, as the price.
If you can’t answer “how do I log this user out of every device right now?”, you don’t want a stateless token.