A JSON Web Token is three Base64url-encoded segments separated by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJleHAiOjE3MzAwMDAwMDB9.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
header payload signature
The three parts
Header, a small JSON object naming the signing algorithm, e.g. {"alg":"HS256","typ":"JWT"}.
Payload, the claims. Registered claim names are short by convention: sub (subject), iss (issuer), aud (audience), exp (expiry, as a Unix timestamp), iat (issued at), nbf (not before). You can add your own.
Signature, computed over base64url(header) + "." + base64url(payload) using the algorithm from the header and a key.
The part people get wrong
Base64url is encoding, not encryption. The payload is readable by anyone who has the token, paste it into any decoder, or run it through atob in a browser console.
So:
- Never put anything confidential in a JWT payload. No passwords, no personal data you would not put in a URL, no internal identifiers you would rather not leak.
- The signature guarantees the token has not been modified. It does nothing to stop it being read.
If you need an encrypted token, that is JWE, a different specification. JWT alone is signed, not encrypted.
Decoding versus verifying
Decoding splits on dots and Base64url-decodes. It requires no key and proves nothing.
Verifying recomputes the signature using the key and compares. Only that step establishes the token is authentic.
A decoder, including ours, shows you what is inside a token. It cannot tell you the token is valid, because it does not have your signing key. That is expected behaviour, not a limitation to work around.
The alg: none problem
Early JWT libraries trusted the alg field in the header. An attacker could set "alg":"none", strip the signature, and some libraries would accept it as valid.
A related attack: a server expecting RS256 (asymmetric) receives a token claiming HS256 (symmetric), and a naive library verifies the HMAC using the public key as the shared secret, a key the attacker also has.
Both are fixed in maintained libraries, and both are still worth knowing, because the underlying lesson generalises: the server must decide which algorithm it accepts. Never take that instruction from the token itself.
Checking a token properly
A verifier should confirm all of the following:
- The signature is valid, using an algorithm the server chose in advance.
expis in the future.nbf, if present, is in the past.issmatches the expected issuer.audincludes this service.
Skipping the expiry check is depressingly common and turns a short-lived token into a permanent credential.
The revocation problem
JWTs are stateless, that is their appeal. The server validates the signature without a database lookup, which scales beautifully.
It also means you cannot revoke one. A stolen token is valid until it expires, full stop. Logging out clears it from the client, and does nothing about a copy an attacker took.
Practical mitigations: keep access tokens short-lived (minutes, not days), use refresh tokens that are stored server-side and can be revoked, and maintain a deny-list for emergencies, accepting that you have reintroduced the state you were trying to avoid.
Where to store one
- localStorage: readable by any JavaScript on the page. One XSS vulnerability and the token is exfiltrated.
- httpOnly cookie: unreadable by JavaScript, so XSS cannot steal it directly. Requires CSRF protection, and
SameSitehandles most of that.
The general recommendation is an httpOnly, Secure, SameSite cookie.
Inspecting tokens safely
Session tokens end up pasted into online decoders during debugging all the time. A live token is a live credential, if the decoder's server logs it, that log is now a set of working keys to someone's account.
Decoding is pure string manipulation, so there is no reason for it to happen anywhere but your own browser. That is how the JWT decoder here works. Whatever tool you use, check that before pasting a production token into it.