The default, and the condition that changes it
For a first-party web or mobile application talking to your own API, server-side sessions are the better default. The client holds an opaque, high-entropy identifier; the server holds the actual state. Every request costs one lookup in a store you already operate, and in exchange you get immediate revocation, cheap privilege changes, and a credential that leaks nothing if it is captured in a log.
The condition that justifies a JWT is a resource server that must validate a credential without a synchronous call to whatever issued it. That is the real problem JWTs solve: cross-service or cross-organisation verification via a signature, so a service holding only the issuer's public key can decide whether to trust the claims. If every request already terminates at infrastructure that can reach your session store in a millisecond, you are paying the cost of a stateless design without collecting its benefit.
The revocation problem, stated honestly
A signed token is valid until it expires, because validation is a local computation over the token's own bytes. There is no point in the design where the issuer gets a vote. If an account is disabled, a role is downgraded, or a token is stolen, the token keeps working until exp passes.
Every mitigation walks back the statelessness. A jti denylist means each verification consults a shared store, which is a session lookup wearing a different hat, with the added quirk that entries must be retained until natural expiry. Token introspection (RFC 7662) is an explicit network call to the authorisation server, which is even more coupling than a session read. Short lifetimes with a long-lived refresh token narrow the window rather than closing it, and the refresh token is itself stateful, because rotation with reuse detection requires the server to remember which refresh tokens it has issued and retired. RFC 7009 defines a revocation endpoint, but it revokes at the authorisation server; a resource server validating locally will not notice.
So the honest framing is: token move you from "revocation is a delete" to "revocation is eventual, bounded by token lifetime". That is acceptable for a five-minute access token in front of an audit-logged service. It is not acceptable for a token you minted with a thirty-day expiry to avoid writing a refresh flow.
Cookie versus localStorage
Put the credential in a cookie with HttpOnly, Secure, and SameSite. HttpOnly removes the credential from JavaScript's reach, so an XSS payload cannot exfiltrate it and replay it later from the attacker's own machine. That distinction matters: XSS is still catastrophic with cookies, because the injected script can issue authenticated requests from the victim's browser, but the damage is confined to the session's lifetime in that browser rather than becoming a portable, long-lived stolen credential.
localStorage is the opposite trade. It is readable by any script on the origin, including every transitive dependency in your bundle, and it has no equivalent of HttpOnly. Teams choose it to avoid CSRF, which is a real property: because the token is attached by application code rather than automatically by the browser, a cross-site request carries no credential. But CSRF has cheap, well-understood defences, whereas token theft via XSS does not. Trading a solved problem for an unsolved one is the wrong direction.
With cookies you owe a CSRF defence: SameSite=Lax (which Chrome applies as the default when the attribute is absent) blocks cookies on cross-site subrequests and on cross-site POST navigations, and for state-changing endpoints you back it with a synchroniser token or a strict Origin header check. Cookie semantics come from RFC 6265; the SameSite attribute was defined in later work on that specification rather than in 6265 itself.
The specific token validation pitfalls
Algorithm confusion is the classic one. The alg header is attacker-controlled input, so a library API that takes a key and reads the algorithm from the token will happily verify an HS256 token whose MAC was computed using your published RSA public key as the shared secret. The related case is alg: none, the unsecured token permitted by the JWS specification, which some libraries have historically accepted as valid. The fix is to pin the accepted algorithms at the call site and never let the token choose.
The second pitfall is decoding without verifying. Most libraries expose both a decode and a verify function, and the decode path parses claims without touching the signature. Reading a userId out of a decoded-but-unverified token is an authentication bypass, not a shortcut.
The third is skipping claim checks. RFC 7519 makes exp, aud, and iss optional, and libraries validate them only when you say what you expect. Without an aud check, a token minted for a low-privilege service is replayable against a high-privilege one. Without iss plus key resolution scoped to that issuer, a multi-tenant deployment can accept a token signed by the wrong tenant's key. RFC 8725 collects these into concrete guidance and is the document to cite.
jwt.verify(token, publicKey);
jwt.(
token,
getKey,
{
: [],
: ,
: ,
},
{
(err) (err);
(claims);
},
);
The trap
The trap is answering on the axis of scalability. Candidates say sessions "don't scale" because they require server state, and token are "stateless, therefore horizontally scalable". A shared Redis or database session store scales perfectly well horizontally; the thing that does not scale is sticky in-memory sessions on individual application servers, which is an implementation choice, not a property of sessions. Meanwhile the stateless design almost always acquires state again the moment someone asks for logout-everywhere or immediate deactivation. Answer on the axis of revocation latency and trust boundaries, and the trade-off becomes something you can actually reason about.
Sessions make revocation a delete and statelessness the thing you give up; token make statelessness free and revocation the thing you give up. Pick the one whose loss your system can survive, and if you pick token, pin the algorithm and assert iss, aud, and exp on every single verification.
© 2026 Preptima. Originally published at preptima.com (opens in a new tab).