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.
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
| Claim | Means | What to check |
|---|---|---|
iss | Issuer | Matches the issuer you configured, exactly. Not by prefix. |
aud | Audience | Names your service. A token minted for another service in the same estate is not yours to accept. |
sub | Subject | The user. Stable and opaque, ideally — not an e-mail address that can be reassigned. |
exp | Expiry (seconds since the epoch) | Present, and short. A bearer token with a year on it is a password that cannot be rotated. |
nbf | Not valid before | Only meaningful if you enforce it. |
iat | Issued at | Useful for detecting replay and for revocation-by-timestamp. |
jti | Token id | The 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 →