JWT Security: How JSON Web Tokens Work and How They Get Abused
· updated · by Andergrove Software
A JSON Web Token (JWT) is signed, not encrypted. The header and payload are just base64url-encoded JSON, so anyone holding the token can read every claim inside it. The signature does not hide the contents; it proves the token has not been altered since the server issued it. Confusing those two ideas, secrecy versus integrity, is behind most JWT mistakes.
Here is how a JWT is actually put together, the attacks that target each part, why the
none algorithm is a trap, when a JWT is the wrong tool entirely, and a checklist
for issuing and validating tokens safely. You can pull any token apart in the
JWT decoder as you read. It runs entirely in your browser,
so it is safe to inspect a real token without sending it anywhere.
Anatomy: three base64 parts
A JWT is three base64url strings joined by dots: header.payload.signature.
- Header. JSON naming the signing algorithm, e.g.
{"alg":"HS256","typ":"JWT"}. - Payload. JSON "claims": who the token is for, when it expires, and whatever else you put in.
- Signature. The header and payload signed with a key.
Decode the first two parts and you get plain JSON. Paste a token into the decoder and you will see the payload in clear text. That is the single most important thing to understand: the payload is encoded, not encrypted. Base64 is reversible by anyone, so never put a secret or sensitive personal data in it.
(If you ever meet a token with five dot-separated parts, it is not a broken JWT — it is an encrypted one. More on JWE below.)
How the signature works
The signature is what makes a JWT trustworthy. The common families:
- HS256 (HMAC + SHA-256). A symmetric secret: the same key signs and verifies. Simple, but anyone who can verify can also forge, so the secret must stay on your servers.
- RS256 (RSA signature). An asymmetric key pair: a private key signs, the matching public key verifies. Useful when third parties need to verify tokens without being able to mint them. PS256 is the same idea with modern RSA-PSS padding.
- ES256 (ECDSA) and EdDSA (Ed25519). Also asymmetric, with far smaller keys and signatures than RSA. EdDSA (RFC 8037) is the newest of the set and sidesteps the nonce-reuse pitfalls of classic ECDSA; it is increasingly the recommended choice where your stack supports it. The decoder verifies and signs all of these, and can generate a test key pair in the browser (as PEM or JWK) if you want to watch the round trip happen.
Either way, the server recomputes the signature over the received header and payload and checks that it matches. Change a single character of the payload and the signature no longer validates. That is the integrity guarantee, and it says nothing about secrecy. The hash underneath HS256 is the same one explained in SHA-256 explained.
alg: none and the illusion of security
The JWT spec (RFC 7519) includes an
"Unsecured JWT": set the header to {"alg":"none"} and leave the signature empty.
It exists for the narrow case where integrity is guaranteed by some other layer of the system.
In the wild it is almost always a vulnerability, and it is worth understanding why it
keeps catching people.
An unsigned token is visually indistinguishable from a signed one. It still
has dot-separated sections, it still decodes to the same official-looking claims, and base64
gibberish reads as "cryptography" to a tired reviewer. Nothing about the token's appearance
tells you whether it is protected — the only thing that makes a JWT trustworthy is
the verifier refusing to accept anything it did not explicitly agree to. That is the illusion
of security: a system passing around alg: none tokens looks exactly like a system
passing around signed ones, right up until someone base64-encodes
{"role":"admin"} by hand.
Forging one requires no tools and no key: decode the payload, edit a claim, re-encode it, set
alg to none, drop the signature. (You can do this in the decoder
with the Edit & re-sign button — and note how the tool immediately flags
the result in red as forgeable.)
This is not theoretical. In 2015, Tim McLean found that many mainstream JWT libraries accepted
alg: none tokens by default, or could be tricked into the related RS256-to-HS256
confusion — see Auth0's write-up,
Critical
vulnerabilities in JSON Web Token libraries. Variants keep resurfacing: libraries that
compare the algorithm name case-sensitively can be bypassed with None or
NONE, and new CVEs in JWT libraries appear every year.
PortSwigger's JWT attack material has
hands-on labs for most of them.
The defense is one rule, stated plainly in
RFC 8725, the JWT Best Current Practices:
the verifier — not the token — decides which algorithms are acceptable. Pin the algorithm and
key server-side; treat the token's alg header as attacker input.
The other classic attacks
- Algorithm confusion (RS256 to HS256). If your code verifies "whatever alg the header says," an attacker can take your public RSA key (public by design), switch the header to HS256, and sign a forged token using that public key as the HMAC secret. A server expecting RS256 then verifies HS256 with the public key, and it matches. The fix is the same: pin the algorithm and key type.
- Weak HMAC secrets. HS256 is only as strong as its secret. A short or dictionary secret can be brute-forced offline from a single captured token. Use a long, random secret.
- Missing expiry or claim checks. A token with no
exp, or a server that never checks it, is valid forever. Always set and verifyexp, and validateaud(audience) andiss(issuer) so a token minted for one service cannot be replayed against another. The decoder's built-in checks flag several of these automatically: missing expiry, lifetimes over a day, missingiss/aud, and claim names that look like secrets in the payload.
Signed is not secret: encrypted JWTs (JWE)
When the claims themselves must be hidden — say an ID token carrying personal data through a
third party — the JOSE family has a second format: JWE
(RFC 7516). A JWE has five
base64url parts (header.encryptedKey.iv.ciphertext.tag) and its header names two
algorithms: alg for key management (RSA-OAEP, ECDH-ES, a wrapped or direct
symmetric key, or a PBES2 password) and enc for the content encryption (AES-GCM
or AES-CBC + HMAC).
Two things to keep straight:
- Encryption is not authentication. A JWE hides the claims, but on its own it does not prove who wrote them the way a signature does. That is why sensitive tokens are usually nested: signed first, then encrypted (
cty: "JWT"in the outer header). - Most APIs do not need JWE. TLS already protects tokens in transit. JWE is for hiding claims from the bearer and intermediaries, which is a much rarer requirement.
If you are handed one, the decoder recognises the five-part
shape, explains the alg/enc pair, and can decrypt it locally with
the matching private key, symmetric key or password — and if the plaintext is a nested signed
JWT, it loads that straight into the decoder so you can verify the inner signature too.
What to put in a token, and what not to
Put in: a subject (sub), an expiry (exp), an issued-at
(iat), the audience (aud) and issuer (iss), and minimal
authorization data such as roles or scopes. Keep it small, because tokens travel on every
request.
Keep out: passwords, API keys, secrets, and any personal data you would not want readable. The
payload is visible to anyone holding the token. (Paste a token into the decoder and it will
warn you if a claim name looks like a credential — api_key,
db_password and friends have all shipped to production inside tokens.)
Are JWTs the right tool for web sessions?
Probably not — and this is the part the tutorials skip. The classic pitch for JWT sessions is statelessness: no session store, any server can verify the token, infinite horizontal scale. The pitch is real, but for an ordinary web application it trades away properties you actually need:
- You cannot revoke a stateless token. Logout, a password change, a stolen laptop, a fired employee — a signed JWT stays valid until
expno matter what. Every fix (a denylist, a session-version claim checked against the database) reintroduces the server-side state you were trying to avoid, at which point you have rebuilt cookie sessions with more moving parts. - The workarounds converge on sessions anyway. "Keep the JWT short-lived and add a refresh token" means storing refresh-token state server-side and running token-rotation logic — more code and more failure modes than a session id in the first place.
- Storage is a trap. Put the JWT in
localStorageand any XSS steals it. Put it in anHttpOnlycookie (the right call) and you have given up most of the claimed advantage over a plain session cookie, while keeping the JWT downsides. Boring session ids inHttpOnly; Secure; SameSitecookies are battle-tested — see the OWASP Session Management Cheat Sheet. - Size and crypto surface. A session cookie is ~30 bytes and has nothing to parse; a JWT is commonly a kilobyte or more on every request (the decoder warns when a token will not even fit in a 4 KB cookie), and it drags in algorithm pinning, key rotation and a history of library CVEs. And the "no database lookup" win is oversold: most requests load user data anyway, and a session lookup in Redis or your database is microseconds.
joepie91's much-cited essay Stop using JWT for sessions walks through these arguments (and the common rebuttals) in detail.
Where JWTs genuinely shine is between systems: OIDC ID tokens, short-lived
access tokens minted by an auth server and verified statelessly by many APIs (fetching the
public keys via JWKS — the decoder's From issuer button does exactly this
discovery), service-to-service calls, webhooks and signed URLs. The rule of thumb: JWTs are a
great federation format and a mediocre session format. If you do use them
for sessions, keep exp short, store the token in an HttpOnly cookie,
and have a revocation story before you need one.
A safe validation checklist
- Pin the algorithm server-side and reject anything else, including
none. - Verify the signature with the right key before trusting any claim.
- Check
exp(andnbfif used) on every request. - Check
audandissmatch your service. - Use a strong, rotated secret (HS*) or protect your private key (RS/ES/EdDSA).
- Keep payloads minimal and non-sensitive.
This list is essentially RFC 8725 in miniature; read the full BCP if you are building or reviewing a JWT implementation.
Inspect a token safely
The fastest way to build intuition is to take a token apart. The Andergrove JWT Decoder splits a token into header, payload and signature and shows the decoded JSON, entirely in your browser, so you can inspect production tokens without pasting them into someone else's server. Decode one, press Edit & re-sign, change a claim, and verify the original signature no longer matches — that mismatch is the whole point of a signed token. Then check what its built-in lint says about your own tokens' lifetime, size and claims.
Further reading
- RFC 7519 — JSON Web Token and RFC 7516 — JSON Web Encryption, the specs.
- RFC 8725 — JWT Best Current Practices, the checklist every verifier should follow.
- Critical vulnerabilities in JSON Web Token libraries — the 2015 disclosure of
alg: noneand RS/HS confusion. - PortSwigger: JWT attacks — hands-on labs for every attack in this post.
- Stop using JWT for sessions — the sessions argument, at length.
- OWASP Session Management Cheat Sheet — what good cookie sessions look like.