JWTs: What They Prove, and the Mistakes That Undo It
Signed, not encrypted. Readable by anyone holding one, and hard to revoke — which decides where they belong.
A JSON Web Token is three base64url segments separated by dots: header, payload, signature. It is signed, not encrypted. Anyone holding a JWT can read every claim inside it — paste one into any decoder and the payload appears in full.
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMiLCJleHAiOjE3MDB9.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
That is the single most misunderstood fact about them. Never put anything in a JWT you would not hand to the bearer.
What the signature actually proves
That the token was issued by someone holding the signing key and has not been altered since. That is all. It says nothing about whether the token is still valid — whether the user has logged out, been deleted, or had permissions revoked. A signature cannot expire; only a claim inside can, and only if you check it.
The failure modes, in order of severity
- Accepting
alg: none. The header declares its own algorithm, and early libraries obligingly honoured a declaration of "none" — no signature required. Always validate against an algorithm you specify, never the one the token asks for. - Algorithm confusion. A token signed with HMAC using the server's RSA public key as the shared secret will verify, if the library picks the algorithm from the header. The public key is public. Pin the expected algorithm.
- Not verifying at all. Most libraries offer a decode function and a verify function. Decode does not check the signature. It is one word apart and it is the whole of the security.
- Ignoring
exp. Expiry is a claim, not a mechanism. If your code does not compare it to the clock, the token is valid forever. - Missing
audandiss. Without them, a token minted for one service is accepted by another that shares the key.
The revocation problem
The appeal of JWTs is that a server can verify one without touching a database — the signature is self-sufficient. That is also the drawback: you cannot un-issue a token. A user logs out, you delete their session, and their JWT keeps working until it expires, because nothing checks anything.
Every mitigation gives back some of the benefit:
- Short expiry plus a refresh token. Access tokens live 5–15 minutes; a longer-lived refresh token is stored server-side and can be revoked. This is the standard answer and it is a reasonable one.
- A denylist of revoked tokens. Works, and reintroduces the database lookup the design was avoiding.
- A per-user token version. Bump a counter to invalidate everything issued before it — again, a lookup.
Where they fit, and where they do not
JWTs earn their keep when verification must happen without shared state: several services validating the same token, or an API gateway checking a request before routing it. They are a poor fit for a single conventional web application, where a session cookie backed by server-side storage gives instant revocation, smaller requests, and far fewer ways to get it wrong.
"We use JWTs for sessions" is usually a decision inherited from a tutorial rather than made. If everything that validates the token can reach the same database anyway, the token is doing no work that a session ID would not do better.
Storage in a browser
localStorage is readable by any script on the page, so one XSS is one stolen token. An HttpOnly, Secure, SameSite cookie is not readable by script, at the cost of needing CSRF protection. The second is the better default, and the fact that it looks less like "stateless auth" is not a technical argument.