JWT, decoded

Three base64url segments and a great deal of trouble. Here is what is in one, and the handful of mistakes that turn a token into an authentication bypass.

A JSON Web Token is header.payload.signature — three base64url segments joined by dots. The header says how it was signed, the payload holds the claims, and the signature covers the first two segments verbatim. Nothing in it is encrypted.

Paste a token into the decoder →

The part people get wrong first

A signed JWT is readable by everyone who touches it. The claims are base64url, not ciphertext. That means the user holding the token, every proxy it passed through, your access logs, your error tracker, and the browser history all have the full contents. Signing stops the claims being changed; it does nothing to stop them being read. Never put anything in a claim you would not put in a URL.

It also means decoding is not verification. Reading a token tells you what it asserts, not whether any of it is true. Verification is recomputing the signature with a key you already trust — with the algorithm pinned by you, not read out of the token — and then checking exp, nbf, iss and aud.

Claims worth reading

ClaimMeansWhat to check
issIssuerMatches the issuer you configured, exactly. Not by prefix.
audAudienceNames your service. A token minted for another service in the same estate is not yours to accept.
subSubjectThe user. Stable and opaque, ideally — not an e-mail address that can be reassigned.
expExpiry (seconds since the epoch)Present, and short. A bearer token with a year on it is a password that cannot be rotated.
nbfNot valid beforeOnly meaningful if you enforce it.
iatIssued atUseful for detecting replay and for revocation-by-timestamp.
jtiToken idThe hook for one-time use and for a revocation list.

The failure modes

alg: none

The specification allows an unsecured token whose algorithm is none and whose signature segment is empty. A library that reads the algorithm out of the header and does as it is told will accept one, and at that point anybody can mint any claim they like. The fix is not to check for none — it is to stop taking the algorithm from the token at all.

Algorithm confusion (RS256 → HS256)

An attacker changes the header from RS256 to HS256 and signs the token with the RSA public key as if it were an HMAC secret. A verifier that trusts the header hands the same public key to an HMAC function, the signature matches, and a value that was never secret has become the signing key. Same root cause: the token chose the algorithm.

jku, x5u, jwk

These headers point at the key — a URL to a JWK Set, a URL to a certificate chain, or a key inline. Honouring any of them lets the token nominate its own key. Ignore them and select from keys you already hold; if you need kid, match it against a local allowlist rather than using it to build a path or a query.

Claims that are checked but not enforced

Verifying the signature and then ignoring exp, aud or iss is common, and each omission is its own bug: an expired token that still works, a token minted for a different service, or a token from an entirely different issuer that happens to use a key you trust.

Storage

A JWT in localStorage is readable by any script that runs on the page, which means one XSS is total session compromise with no way to revoke. A cookie with HttpOnly, Secure and SameSite is out of JavaScript's reach — see the Set-Cookie builder for the exact attribute set, and the XSS guide for what an injected script can otherwise do with it.

And because tokens are bearer credentials, keep them short-lived. Revocation is the thing JWTs are worst at: once minted, a token is valid until it expires or the signing key changes, and neither of those is a per-user operation.

Open the decoder →