JSON Web Tokens are the default answer to authentication in modern APIs, and they are misused in the same handful of ways across a remarkable number of systems. JWT security is not mainly about the cryptography — that part is mostly handled by libraries — but about a set of design decisions that are easy to get wrong and hard to walk back once real users depend on them. The mistakes are predictable, which is good news: predictable mistakes are preventable.
Understand what a JWT actually is
A JWT is a signed, not encrypted, bundle of claims. That first fact trips people up constantly: the payload is Base64-encoded, not secret, so anyone holding the token can read every claim inside it. The signature guarantees the token has not been altered, not that its contents are hidden. Putting anything sensitive in a JWT payload — a password, a private detail — is exposing it to anyone who intercepts or inspects the token.
The genuine appeal is that the signature lets a server verify a token without a database lookup: it checks the signature with a key it already holds and trusts the claims. That statelessness is JWT’s whole value proposition, and — as we will see — also the source of its sharpest limitation. Nearly every JWT design mistake is a failure to reckon with the consequences of that statelessness.
Where you store the token decides your attack surface
The most consequential decision, and the one made most casually, is where the token lives in the browser. The two common choices have opposite risk profiles and neither is free.
localStorage is easy and it is vulnerable to XSS. Any JavaScript on your page — including a compromised third-party script — can read localStorage and exfiltrate the token, and since JWTs are bearer tokens, whoever holds it is the user. One XSS bug becomes total account takeover. An httpOnly cookie cannot be read by JavaScript, which closes the XSS theft vector, but a cookie is sent automatically with requests, which opens a CSRF vector you must then defend against with SameSite and anti-CSRF tokens.
The generally sounder default is an httpOnly, Secure, SameSite cookie, because XSS is more common and more devastating than CSRF, and SameSite handles most of the CSRF risk on its own — the reasoning laid out in our security headers guide. Storing a bearer token in localStorage is the convenient choice that turns every XSS bug into a full compromise, and it is worth avoiding for exactly that reason.
You cannot log someone out: the revocation problem
Here is the limitation that surprises people and causes real incidents: you cannot revoke a JWT. The entire point of a JWT is that the server validates it without a lookup — which means the server has no list of valid tokens to remove one from. A token is valid until it expires, full stop. If a token is stolen, or a user clicks “log out everywhere”, or you need to force-terminate a session, a pure stateless JWT gives you no mechanism to do any of it. The signature still checks out; the server still trusts it.
This is not a bug, it is the direct consequence of statelessness, and pretending otherwise is how “log out” becomes a lie that leaves stolen tokens live for hours. The standard mitigation is the pattern you should reach for by default: short-lived access tokens plus long-lived refresh tokens. The access token lasts minutes, so a stolen one is only useful briefly; the refresh token is longer-lived, stored more carefully, and — crucially — is tracked server-side, so it can be revoked. When you need to kill a session, you invalidate the refresh token and wait out the access token’s short life.
Notice what that means: the moment you need revocation, you have reintroduced server-side state. You are no longer fully stateless. That is fine and correct, but it undercuts the “JWTs are stateless” pitch, and it is worth being honest that the stateless story only holds until you need to log someone out — which every real system eventually does.
Algorithm confusion and the “alg: none” trap
A classic JWT attack exploits the token telling the server which algorithm to use to verify it. The header includes an alg field, and a naive verifier trusts it. Two attacks follow. In the first, an attacker sets alg to none and removes the signature, and a permissive library accepts the unsigned token as valid. In the second, an attacker changes a token signed with RSA to claim it is signed with HMAC, tricking a server into verifying it with the RSA public key as if it were an HMAC secret — a key the attacker knows.
The defence is to never trust the token’s own claim about its algorithm. Configure your library to accept only the specific algorithm you expect, and reject everything else, rather than letting the incoming token choose. Modern libraries default to this, but it is worth verifying rather than assuming, because the failure is silent — an accepted forged token looks exactly like a legitimate one.
Sometimes a JWT is the wrong tool
The most useful thing to internalise is that JWTs are not automatically the right choice, and reaching for them by reflex is itself a common mistake. If your application is a traditional server-rendered site or a single backend serving one front-end, a plain server-side session — a random session ID in a cookie, with the session data in your datastore — is simpler, revocable by default, and does not carry any of the storage or revocation problems above. You get “log out everywhere” for free because the state was always on the server.
JWTs earn their complexity in specific situations: multiple services that each need to verify identity without calling a central auth server, or genuinely stateless horizontal scale where a shared session store is a bottleneck. In those cases the stateless verification is a real advantage. Outside them, a JWT is often a more complex, harder-to-revoke answer to a problem a session cookie solved decades ago. For the strongest login security, the token question is upstream of a bigger one — moving to phishing-resistant credentials, as in our passkeys and WebAuthn guide, changes the threat model more than any token-storage choice.
Refresh token rotation, and detecting a theft
The short-plus-refresh-token pattern raises an obvious question: if the refresh token is long-lived and powerful, what happens when it gets stolen? The answer is a technique called rotation, and it is worth implementing because it turns a stolen refresh token from a permanent compromise into a detectable, self-limiting one.
Rotation means each refresh token can be used exactly once. When a client exchanges its refresh token for a new access token, it also receives a new refresh token, and the old one is immediately invalidated. The legitimate client always holds the latest token and never notices. But now consider a thief who has stolen a refresh token. Either the thief uses it before the legitimate client does, or the client uses it first — and in both cases, the token gets used twice: once by whoever used it first, and once by the other party holding the now-invalidated copy.
That double-use is the signal. When your server sees an already-invalidated refresh token presented, it knows something is wrong — a token that should only ever be used once is being used again — and the correct response is to revoke the entire token family, forcing a fresh login. You have converted an undetectable theft into an event that trips an alarm and logs the attacker out, which is a dramatically better security posture than a stolen refresh token quietly working for weeks.
This does require server-side state to track which refresh tokens are current and which families have been revoked — the same reintroduction of state discussed above, and worth the trade. It pairs naturally with a few defensive touches: bind refresh tokens to a device or client where you can, keep them in the most protected storage available, and give them a finite absolute lifetime so even an undetected one eventually expires. Rotation with reuse detection is the difference between “we hope nobody stole the token” and “if someone stole the token, we will know and cut it off” — and for anything holding a long-lived credential, that difference is the whole point.
The short version
Remember a JWT is signed, not secret, so nothing private goes in the payload. Prefer an httpOnly cookie to localStorage, because XSS is the more dangerous threat. Plan for revocation from the start with short access tokens and revocable refresh tokens, and accept that this reintroduces server state — the stateless dream ends the moment you need to log someone out. Pin the verification algorithm so the token cannot choose it. And ask honestly whether you need a JWT at all, because a session cookie is simpler and safer for a great many applications that reached for tokens out of habit.