Sessions, JWTs, and the Logout Problem
JWTs are chosen for statelessness and then made stateful by the first real requirement: logout. Better to know that before choosing than after.
The decision is usually made on one word. Sessions are stateful, therefore they do not scale; JWTs are stateless, therefore they do. Both halves of that are wrong in ways that matter, and the requirement that exposes it is not exotic — it is logging out.
The actual difference
A session cookie is a reference. It holds a random identifier and nothing else; the server looks up what it means. The client cannot read anything from it, and the server can change or revoke what it points at instantly.
A JWT is a value. It carries the claims themselves — user id, roles, expiry — signed so the server can verify it was not modified. No lookup required, and that is the entire selling point.
Two properties follow directly, and everything else is downstream. A JWT is valid until it expires, because validity is a property of the token rather than of a record you control. And a JWT is a snapshot — the roles inside it were true when it was issued and may not be true now.
Note what is not a difference: signed does not mean encrypted. The payload of a standard JWT is base64, readable by anyone holding it. Putting anything sensitive in there publishes it to the client and to anything that logs the header.
The logout problem
A user clicks log out. With a session, you delete the record and the credential is dead — one operation, immediate, total.
With a JWT you delete it from the browser, and the token remains valid for the rest of its lifetime. If it leaked or was copied — the exact scenario in which a user urgently wants to log out — you have done nothing. The same applies to every adjacent requirement:
- An administrator suspends an account: still valid until expiry.
- A user changes their password after a compromise: old tokens still work.
- Permissions are downgraded: the old roles remain in the token.
- "Log out of all devices": nothing to revoke.
Every solution reintroduces state. A denylist of revoked token ids requires a lookup per request — which is a session lookup with more steps. A version number per user, incremented on logout and compared against the token, is also a lookup. Very short expiry with silent refresh narrows the window but makes the refresh token the real credential, and that one must be revocable, so it must be stored.
The conclusion worth internalising: any system with real logout is stateful somewhere. The question is only where you put the state, not whether you have it. JWTs are not wrong — they are excellent for short-lived, self-contained assertions between services. They are just not free of the thing they are chosen to avoid.
Refresh rotation, done properly
The standard pattern: a short-lived access token, and a long-lived refresh token that mints new ones. That reduces the exposure window, but it moves the valuable credential to the refresh token, which now needs the protection.
The mechanism worth implementing is rotation with reuse detection:
- Every refresh issues a new refresh token and invalidates the old one.
- Tokens are stored server-side as a family — a chain of tokens belonging to one login session.
- If an already-used refresh token is presented, revoke the entire family.
That last rule is the point. A used refresh token being presented again means two parties hold it — the legitimate client and a thief. You cannot tell which one is asking, so you invalidate everything and force a fresh login. The attacker is locked out and the real user is inconvenienced once, which is the correct trade.
Two implementation notes. Store a hash of the refresh token, not the token — it is a credential in your database and should be treated like a password. And give the family an absolute lifetime as well as a sliding one, so a session cannot be extended indefinitely by an attacker who refreshes it every few minutes forever.
Where to store it, which is the part people get wrong
localStorage is convenient and is readable by any JavaScript running on your page. Any successful XSS — from your code, a dependency, or an analytics script — reads the token and exfiltrates it. A stolen token needs no further interaction and works from anywhere.
An httpOnly cookie cannot be read by JavaScript at all. XSS can still act as the user while they are on the page, which is bad, but it cannot steal a durable credential to use later from elsewhere. That difference — session-bound abuse versus permanent theft — is large enough to settle the question.
The flags that matter, all of them:
Set-Cookie: session=<value>;
HttpOnly; // no JavaScript access
Secure; // HTTPS only
SameSite=Lax; // not sent on cross-site requests
Path=/;
Max-Age=1209600Cookies bring CSRF back into scope, which is the reason people avoid them and it is a solved problem. SameSite=Lax stops the classic cross-site form post by itself. For anything sensitive, add a double-submit token or the origin check, and be aware that SameSite=None — required for genuine cross-site use — removes that protection and puts CSRF defence back on you entirely.
The one legitimate case for a token in memory is a browser client on a different origin from the API where cookies are impractical. Keep it in a JavaScript variable, never in localStorage, and accept that a page refresh requires a silent refresh call.
Authorisation, which is where the real bugs are
Authentication is who you are. Authorisation is what you may do, and it is where the security incidents happen — usually not because a check was wrong, but because it was missing on one endpoint out of ninety.
Two rules that prevent most of it. First, deny by default: a route with no explicit policy should fail closed rather than open, so forgetting a decorator produces a 403 rather than a leak. Second, check the object, not just the role. This is the most common real vulnerability in business applications:
// authenticated, role-checked, and still broken
app.get("/invoices/:id", requireRole("member"), async (req, res) => {
const invoice = await db.invoices.findById(req.params.id);
res.json(invoice); // whose invoice?
});Any member can read any invoice by changing the number in the URL. The role was right; the ownership was never checked. The durable fix is structural rather than vigilant — scope every query by the tenant or owner from the session, so an unscoped read is not something you have to remember to avoid but something the data layer will not do.
Keep roles coarse and put the fine-grained logic in one policy module rather than in scattered conditionals. When the question “who can edit this?” has one answer in one file, it can be reviewed and tested; when it is spread across forty handlers, it cannot.
A reasonable default
For a normal web application with a first-party frontend: server-side sessions in an httpOnly cookie, backed by Redis or your database. Revocation works, permission changes are immediate, active sessions can be listed and killed individually, and the lookup is sub-millisecond. The scalability objection does not survive contact with the actual numbers.
Choose JWTs deliberately when you have the situation they are for: service-to-service assertions, a third party that must verify without calling you, or genuinely no shared store between the parties. Then accept the consequence — keep them short-lived and build the revocation path anyway, because you will need it.
And unless authentication is your product, use a well-maintained library or provider. Password hashing, rotation, CSRF, timing-safe comparison and account recovery are each an opportunity to get one detail wrong in a way that is silent until it is not.
The short version
Stateless is a property you lose the moment logout has to work, so choose where the state lives rather than whether it exists. Rotate refresh tokens and revoke the whole family on reuse. Put the credential in an httpOnly cookie and handle CSRF rather than accepting permanent token theft. Deny by default, scope every query by owner, and keep the policy in one place. Sessions are the right default; JWTs are a deliberate choice for a specific shape of problem.