JWT mistakes that become breaches

A JSON Web Token is a signed statement, not a sealed envelope. It is three Base64url-encoded segments joined by dots — a header naming the signing algorithm, a payload of claims about a user or a session, and a signature over the first two. The signature proves the token was issued by whoever holds the key and has not been altered since. It proves nothing about secrecy: anyone who holds the token can decode the payload and read every claim in it, which is why decoding one takes a paste rather than a password. When JWTs fail in production, the cryptography is almost never what broke. What broke is a check somebody skipped — the algorithm taken on trust from the token itself, the expiry nobody validated, the shared secret a human could have typed from memory. Paste any token into the JWT Decoder and its header and claims sit there in plain view. This article walks through the engineering mistakes that turn those skipped checks into breaches, and the defensive rule that closes each one.

Signed is not the same as secret

The payload is Base64url — an encoding, the same family as plain Base64 — not encryption. Everything in it is readable by anyone the token passes through: email addresses, roles, tenant IDs, internal account numbers, occasionally things far worse, like password-reset flags or fragments of personal data a developer added for debugging and never removed. Tokens also travel further than developers picture. They sit in browser storage, pass through proxy and application logs, get copied into crash reports and pasted into bug tickets and chat threads. The working rule follows directly: never put a claim in a token that you would not be comfortable seeing printed in a log file, because sooner or later it will be.

The misunderstanding is understandable — a compact token looks like ciphertext, all unpronounceable characters and dots. But the signature only makes the token tamper-evident: change a claim and verification fails. Confidentiality is a different construction in the same standards family (encrypted tokens, JWE), and it is rarely what a plain access token is. So design the payload as public text with a signature on it: identifiers and authorisation facts only, sensitive data fetched server-side by ID, and lifetimes kept short so that whatever does leak expires quickly.

Letting the token pick its own algorithm

The header declares an alg, and the oldest failure in the JWT book is a verifier that believes it. One declared value is none — an unsigned token, no signature at all. A library or hand-rolled check that honours the token’s own algorithm claim can be handed a token with an empty signature and accept it as genuine. The best-practice RFC is blunt about this: unsigned tokens are only acceptable where the channel itself already guarantees integrity, which a bearer token arriving over the public internet is not. The same trust-the-header flaw produces algorithm confusion. A system that issues RS256 tokens holds a private key and publishes the matching public key for verification. If the verifier can be persuaded to process the token as HS256 instead, the verification material becomes the public key — public by design, known to everyone — used as a symmetric secret, and signatures anyone can produce start verifying. No key is stolen in either failure; the verifier simply followed instructions written by the sender.

The header carries a second, quieter risk: key pointers. Parameters such as kid (a key ID), jku and x5u (URLs where a key or certificate lives) tell the verifier where to find the verification key — and they arrive inside the attacker-influenced token. If a kid value flows unchecked into a file path, a database lookup or a key fetch, the lookup itself becomes the attack surface. The defensive rules are all the same shape: pin the expected algorithm in server-side configuration and reject everything else; resolve keys from your own registry, with key IDs checked against an allowlist; never fetch key material from a URL the token supplies; and treat every header field as untrusted input, because it is.

The signature passed; the claims were never checked

Verifying a signature answers one question — was this token issued by the key holder and left unaltered? It says nothing about whether the token should be honoured here, now. That is the claims’ job, and claim validation is where production systems quietly skip steps. exp first: a verifier that never checks expiry turns every token into a permanent credential, so a token leaked in a log three years ago still authenticates today. iss next: accept tokens only from issuers you configured, not from anyone who happens to share an algorithm. And aud, the most expensive omission: organisations often run several services against one signing key, and a low-value service’s token — a free-tier API, a staging-adjacent tool — is then a correctly signed token that a high-value service accepts, because the signature is genuine and nobody asked which audience the token was minted for.

None of this is exotic. It is a checklist, and the failure mode is that libraries verify the signature enthusiastically while leaving claim checks optional, disabled, or the caller’s problem. The defensive posture is to require exp, iss and aud on every verification, fail closed on anything missing or mismatched, and allow clock skew in minutes, not hours — skew tolerance exists for imperfect clocks, and stretched far enough it quietly re-extends expired tokens. Then test the negative cases the way you would test a login form: an expired token, a wrong-audience token and a token from an unknown issuer should all be rejected, and those rejections are worth logging as the security signal they are.

Secrets a human can remember are secrets an attacker can test

HS256 is symmetric: one shared secret both signs and verifies. In real codebases that secret is too often a word or short phrase — a company name, a framework default copied from a tutorial, something a developer could type from memory. Here is the principle that makes that fatal, and it is a property of the design rather than of any tool: a captured token carries everything needed to test a guessed secret offline. The message is known, the signature is known, so guesses can be checked locally, at full speed, without the server ever seeing an attempt or a rate limit ever applying. The security of the whole scheme therefore reduces to the entropy of that one string — and human-chosen strings have very little.

The fixes are unglamorous. Generate signing secrets with a machine, at a proper length — at least 256 bits of entropy for HMAC — and store them like the production credentials they are. Make them unique per environment, so the secret in a leaked staging configuration buys nothing in production, and never let a development default survive into a real deployment. Where many services verify tokens, prefer asymmetric algorithms such as RS256 or ES256: verifiers then hold only public keys, so a compromised verifier leaks nothing that can mint tokens, and the signing key stays in one place. And have a rotation story before you need one, because with a symmetric secret there is no alarm when it leaks — the first sign is usually a token you did not issue being accepted as yours.

Where the browser keeps the token decides who can read it

On the client, the storage choice is a security decision whether anyone made it deliberately or not. A token in localStorage is readable by every script that runs on the page: your own code, every third-party tag and analytics snippet you have admitted — and any cross-site scripting payload that ever executes, from one unescaped comment field anywhere in the app. A bearer token is a copyable credential: whoever reads it can present it from anywhere and be that user until it expires. Long-lived tokens in script-readable storage are therefore worth exactly as much as your worst XSS bug, and most applications eventually have one.

The usual alternative, an HttpOnly cookie, removes script access entirely — an XSS payload can no longer read and exfiltrate the token — but the browser attaches the cookie automatically, which makes cross-site request forgery the threat to engineer against, with SameSite attributes and CSRF tokens. Keeping tokens only in memory dodges both problems and loses the session on every refresh. There is no universally correct answer, and pretending otherwise is how teams end up with the worst of each. What turns storage into a breach is the combination nobody chose on purpose: script-readable storage, long lifetimes, broad scopes and no refresh-token rotation to shrink the value of a copy. Pick the trade-off consciously, keep access tokens short-lived either way, and write the reasoning down where the next developer will find it.

Sources and further reading

Frequently asked questions

Is the data inside a JWT encrypted?

No. In a standard signed JWT the header and payload are Base64url-encoded, which anyone can decode — open a token in the JWT Decoder and every claim is readable in plain text. The signature provides integrity and proof of origin, not confidentiality. Encrypted JWTs (JWE) exist but are the exception in practice. Treat every claim as public to whoever holds the token, and to every log, proxy and screenshot the token passes through.

We verify the signature. Why is that not enough?

Because a valid signature only proves the token was issued by the key holder and not altered since. It says nothing about whether the token is expired (exp), came from the issuer you trust (iss), or was minted for your service rather than another one sharing the same key (aud) — and it says nothing if your verifier let the token choose the algorithm or supply the key location. Signature verification is the first half of validation. The claims checklist, with the algorithm pinned on your side, is the second half, and breaches live in the gap between them.

Should tokens live in localStorage or in a cookie?

Each option trades one attack for another. localStorage is readable by any script on the page, so a single XSS flaw or a rogue third-party script can copy every stored token. An HttpOnly cookie cannot be read by scripts at all, but it is sent automatically, which makes CSRF the threat to manage with SameSite and CSRF tokens. Either can be defensible with short access-token lifetimes and refresh-token rotation. The indefensible option is choosing by default, using long lifetimes, and never revisiting the decision.