OwlTheEngineer

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.

Owl Backend 5 min read

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”.

FlagWhat it stops
HttpOnlyJavaScript reading the cookie — kills the easiest XSS payoff
SecureThe cookie ever travelling over plain HTTP
SameSite=LaxMost CSRF, by not sending it on cross-site POSTs
Max-Age / ExpiresA 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:

  1. 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.
  2. 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 says admin and it still verifies. It will keep saying admin until exp.

That second one is the whole problem.

What each one costs you

SessionJWT
Where state livesYour storeThe token
Reading it per requestA store lookupA signature check
Revoke one loginDelete the rowYou can’t, until exp
Change a roleNext request sees itNext token sees it
Size on every request~30 bytes~400–1000 bytes
Works across domainsAwkwardEasy
Extra infrastructureA storeNone

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_version on 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

  1. Putting a JWT in localStorage. Now any XSS reads your token directly. A cookie with HttpOnly can’t be read by script. You can absolutely put a JWT in an HttpOnly cookie — and if you do, ask yourself what the JWT is still buying you.
  2. 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.
  3. Trusting claims you didn’t verify. Decoding is not verifying. If your code path reads payload.role before checking the signature and exp, 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.