A client asked me to log a user out. Their account had been compromised and support wanted the session killed immediately. I opened the code and found the answer was: we cannot. Authentication was a JWT with a seven-day expiry, verified statelessly on every request, with no server-side record of it existing. That token was valid for six more days and there was nothing in the system that could stop it.
We shipped a token blacklist that afternoon — a Redis set checked on every request. Which is to say we added a database lookup to every authenticated request, and thereby rebuilt sessions, badly, on top of a mechanism chosen specifically to avoid them.
That is the JWT story in miniature, and it happens constantly.
What Each One Actually Is
A session means the server stores the truth. The client holds an opaque ID in a cookie; the server looks it up in Redis or a database and gets the user. Revocation is a delete. Permission changes take effect on the next request. The cost is a lookup per request — typically well under a millisecond against Redis.
A JWT is a signed claim the client carries. The server verifies the signature and trusts the contents without any lookup. No shared state, which is genuinely useful in some architectures. The cost is that the token is true until it expires, and nothing you do server-side changes that.
The trade is not "modern versus old". It is "stateless and unrevocable" versus "stateful and controllable". Almost every team I have seen picks the first and then spends months reintroducing the second.
The Revocation Problem Is the Whole Problem
Once you accept that a valid JWT cannot be withdrawn, look at what that means for ordinary product requirements:
Log out everywhere. Cannot be done. Deleting the client's copy does nothing about a copy an attacker made.
Password change invalidates sessions. Standard expectation, impossible with pure JWTs.
Permission changes. You demote an admin and they keep admin rights until the token expires. If roles are in the token, this is a live security hole for the length of your expiry window.
Account suspension. Same problem, worse consequences.
Compromised token. The case that started this.
The standard answer is short-lived access tokens plus a refresh token — say fifteen minutes and thirty days. That helps, and it is what I would do if I needed JWTs. But be clear about what it costs: you now store and rotate refresh tokens server-side, so you have state again, plus token rotation logic, plus a reuse-detection scheme so a stolen refresh token gets caught. It is strictly more machinery than a session table, and the revocation window is still fifteen minutes rather than zero.
When JWTs Are Genuinely the Right Answer
I am not against them. I am against the default. Cases where they earn their complexity:
Service-to-service authentication. Short-lived, machine-issued, narrowly scoped. This is what the format is good at.
Cross-domain or third-party access where a cookie will not travel and you cannot share a session store.
Genuinely stateless verification at the edge — an API gateway or CDN worker that must accept or reject without calling home. Real requirement, and JWTs solve it.
Short-lived signed URLs and one-time links — password reset, email verification, a temporary download. Fifteen-minute expiry, single purpose. Perfect fit.
Notice the pattern: short lifetimes, narrow scope, or a hard constraint that rules out shared state. "We might want to scale horizontally one day" is not on that list, because Redis scales horizontally perfectly well.
If You Use Them, Get These Right
The implementation mistakes are consistent enough to list.
Pin the algorithm. Never let the token's own header tell you how to verify it. The alg: none and RS256-downgraded-to-HS256 attacks both come from trusting attacker-supplied metadata.
// Wrong — the token decides how it is checked.
jwt.verify(token, secret);
// Right — you decide.
jwt.verify(token, publicKey, {
algorithms: ["RS256"], // pinned
issuer: "https://api.example.com",
audience: "example-web",
maxAge: "15m",
});
Do not put anything secret in it. A JWT is signed, not encrypted. Anyone holding it can read the payload — it is base64, not encryption. I have seen internal user IDs, email addresses and role hierarchies sitting in tokens that were also being logged.
Store it in an httpOnly cookie, not localStorage. This is the most common disagreement and the answer is not close. Anything in localStorage is readable by any script on the page — one compromised npm package and your tokens leave. An httpOnly cookie with SameSite=Lax and Secure cannot be read by JavaScript. The usual objection is CSRF, which is what SameSite and a CSRF token are for. "JWTs mean you do not need cookies" is a misunderstanding: the cookie is transport, the JWT is format, and you can use both.
Keep roles out of the token unless you accept that permission changes lag by the full token lifetime. Put the user ID in, look up permissions per request. Yes, that is a lookup. You are back to sessions with extra steps, which is rather the point.
What I Actually Build
For a normal web application with a browser front end — which is most products — I use server-side sessions:
// Opaque, random, meaningless to the client.
const sid = crypto.randomBytes(32).toString("base64url");
await redis.set(`sess:${sid}`, JSON.stringify({ userId, createdAt: Date.now() }),
"EX", 60 * 60 * 24 * 14);
res.cookie("sid", sid, {
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: 14 * 24 * 3600 * 1000,
});
// Log out everywhere: one command.
// await redis.del(...(await redis.keys(`sess:*`)).filter(byUser));
A Redis lookup adds a fraction of a millisecond to a request that already talks to a database. In exchange, revocation is instant, permission changes are immediate, and "log out all devices" is a feature rather than a redesign.
For a mobile app or a public API, I would use short-lived access tokens with rotating refresh tokens — and I would store the refresh tokens server-side, because I want to be able to revoke them.
The Question to Ask
Before choosing, answer this: what happens when you need to log someone out right now?
If the answer requires a blacklist, a lookup, or waiting for expiry, you have chosen statelessness and then paid for state anyway. That is fine if statelessness is buying you something specific. It usually is not — it is buying you the feeling of being modern, and costing you a feature support will ask for within the first year.
My client's compromised account stayed logged in for the rest of that afternoon while we shipped the blacklist. Sessions would have made it a one-line delete.



