Skip to content
Discovery Authentication 5 min read · Updated 5 Aug 2026

JWT vs Session Authentication: Where the State Lives

beginner authenticationjwtsessions

Both approaches answer the same question on every request: which user is this? The disagreement is over where the answer is kept. A session keeps it on the server and sends the browser a claim ticket. A JWT writes the answer down, signs it, and hands the whole thing to the browser.

Everything else people argue about — scaling, revocation, token size, mobile apps — is a consequence of that one decision.

Session and JWT authentication compared by where state is stored In the session row, the browser sends a session id cookie, the API looks the id up in a session store which holds the source of truth, and returns a response. Revoking is a single row delete and takes effect on the very next request. In the JWT row, the browser sends a bearer token, the API verifies the signature locally with no store to consult, and returns a response. There is nothing to delete, so a revoked token keeps working until it expires. SESSION — the state lives on the server Browser cookie: sid API looks it up Session store source of truth 200 OK or 401 revoke = DELETE one row → 401 on the very next request JWT — the state lives in the token Browser bearer: eyJ… API verifies sig 200 OK always no store to consult revoke = nothing to delete — it works until exp
Where the state lives. The happy paths are almost identical, which is why teams pick by looking at them. The difference is one lookup: the session row consults a store, the JWT row verifies a signature locally. Read the bottom of each row instead — revoking a session is a DELETE that takes effect immediately; revoking a JWT is a wait.

What a session actually is

The server stores a record — user id, expiry, maybe a device — and gives the browser a random opaque id in a cookie. The cookie means nothing on its own. On each request the server looks the id up, finds the record, and knows who is calling.

That lookup is the thing people want to remove. It is also the thing that makes everything else easy: to log someone out everywhere, delete their rows; to force re-login after a password change, delete their rows; to see who is signed in right now, read the table.

What a JWT actually is

Three base64 segments: a header, a payload of claims, and a signature over the first two. The server verifies the signature with a key it already holds, and if it verifies, trusts the claims inside.

The critical property, and the one most often missed: a JWT is signed, not encrypted. Anyone holding the token can read every claim in it.

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyLTMxIiwiZXhwIjoxNzg2MH0.4vQ…
└──── header ────┘ └──────── claims, readable ────────┘ └ sig ┘

The trade-off, stated honestly

Sessions cost a read per request. In practice that read is against Redis and takes well under a millisecond, which for the overwhelming majority of applications is not a problem anyone would have noticed if they had not gone looking for it.

JWTs cost revocation. There is no server-side record, so there is nothing to delete. A stolen token, a fired employee, a password reset — none of them invalidate anything. The token works until exp.

The standard fix is a denylist of revoked token ids, checked on every request. This works, and it is worth noticing what it is: a database read per request. You have arrived back at sessions, with extra steps and a larger cookie.

So when are JWTs the right call

They earn their keep when the verifying party cannot reach your session store:

  • Service to service. A downstream service can verify a signature without a network call to your auth service. This is the strongest case, and it is why the pattern exists.
  • Across trust boundaries. OAuth access tokens, federated identity, a partner’s API validating tokens you issued.
  • Genuinely large scale, where a per-request read against a shared store is a measured bottleneck rather than an assumed one.

For a single web application with one backend and one database, sessions are the better default. The lookup is cheap, and instant revocation is worth more than removing it.

The pattern that works in practice

Use both, for what each is good at:

  1. A short-lived access token — five to fifteen minutes. Stateless, verified locally, no lookup. Its short life is the revocation window.
  2. A long-lived refresh token — stored server-side, one row per session, revocable. Used only to mint new access tokens.

Logging someone out deletes the refresh row. Their access token stays valid for its few remaining minutes, and that bounded window is the whole compromise. If minutes is too long for your risk profile, you do not want stateless tokens at all — you want sessions, and you should say so rather than bolting a denylist onto a JWT and calling it stateless.

Rotate refresh tokens on every use, and treat reuse of an already-consumed refresh token as theft: revoke the whole family. That single rule catches most real-world token compromise.

Storage, which is where the actual breaches happen

Whatever you choose, put it in a cookie with HttpOnly, Secure and SameSite=Lax (or Strict). localStorage is readable by every script on the page, so one XSS anywhere — a dependency, an analytics snippet, a comment widget — is a total credential compromise. HttpOnly removes that class of attack entirely, and it costs nothing.

The common objection is that mobile apps and cross-origin SPAs cannot use cookies. Mobile apps should use the platform keychain, which is better than either. Cross-origin SPAs can use cookies with correct CORS and SameSite=None; Secure — it is configuration, not an impossibility.

What you have actually decided

Not “stateless versus stateful” — a system with users is stateful by definition, and the state does not disappear because you moved it into the client’s pocket.

What you decided is who holds the truth and how fast you can change your mind about it. Sessions keep it and can change it instantly. JWTs lend it out and cannot take it back before it expires. Pick the one whose failure mode you can live with, and size the expiry to how long you are willing to be wrong.

Quick answers

Is JWT better than session authentication?
Neither is better in general. JWTs remove a database read per request, which matters at high volume or across services that cannot share a session store. Sessions keep revocation instant and simple. For a single web application, sessions are usually the better default.
Why can't you revoke a JWT?
Because nothing is stored server-side to delete. The token is valid if its signature verifies and it has not expired, so a server with no extra state has no way to say "not this one". Adding a denylist works, but it reintroduces the per-request lookup that JWTs existed to avoid.
Should JWTs be stored in localStorage or cookies?
Cookies, marked HttpOnly, Secure and SameSite. Anything in localStorage is readable by any JavaScript that runs on the page, so a single XSS flaw hands over the token. HttpOnly cookies are not readable by script, which removes that entire class of theft.
How long should a JWT last?
Minutes, not days. The expiry is your only revocation mechanism, so it is also your worst-case exposure window after a token is stolen. Pair a short access token with a longer refresh token that is stored server-side and can actually be revoked.

References

Related Discoveries