JWTs,
and what they do not prove.
A JWT is three base64url strings with dots between them. Two of them are readable by anyone holding the token. The third is the only part doing any security work, and it is the part most tutorials skip.
What is in a token
Header, payload, signature. The first two are base64url encoded JSON, which is an encoding, not encryption. Anyone with the token can read every claim in it, in any browser console, in about two seconds.
That single fact drives most of the rules that follow. A JWT is a signed postcard, not a sealed envelope.
Never put anything secret in a payload. Not a password, not a card number, not an internal identifier you would not show the user. They can already read it.
The standard claims
| Claim | Meaning | Common mistake |
|---|---|---|
| iss | Issuer | Not checked at all |
| sub | Subject, usually the user | Assumed unique across issuers |
| aud | Intended audience | Not checked, so a token for service A works on B |
| exp | Expiry, unix seconds | Written in milliseconds |
| nbf | Not valid before | Ignored, breaking clock skew handling |
| iat | Issued at | Treated as expiry |
exp in milliseconds is the single most common bug. A token that appears to expire in 1970 or in the year 55000 is almost always a units error.
Decoding is not verifying
Decoding splits the string and base64 decodes it. It requires no key and proves nothing. Verifying recomputes the signature with the key and compares. Only the second tells you the token is genuine and unmodified.
Any tool that shows you the payload has decoded it. That is useful for debugging and worthless as a security check. Verification belongs on your server, with your key, which is also why you should never paste a production signing key into a web page.
Two attacks worth knowing
alg: none
The spec allows an alg of none, meaning unsigned. A naive library reads the algorithm out of the header, sees none, and skips verification. The attacker rewrites the payload, sets alg to none, drops the signature, and is now whoever they like.
The fix is to never trust the header. Decide server side which algorithm you accept and reject anything else.
Key confusion, RS256 to HS256
RS256 verifies with a public key. HS256 verifies with a shared secret. If the server picks its algorithm from the header, an attacker can take your public key, sign a token with it as if it were an HMAC secret, and set alg to HS256. The server then verifies with the same public key and accepts it.
Same fix. Pin the algorithm.
Where to store a token
localStorage is readable by any JavaScript on the page, so a single cross site scripting hole hands over every token. A cookie with HttpOnly, Secure and SameSite is not readable by script at all, which removes that class of attack, at the cost of needing CSRF protection.
Neither is unconditionally right. The honest summary is that HttpOnly cookies trade an XSS problem for a CSRF problem, and CSRF is the easier of the two to defend.
Expiry and revocation
A JWT is valid until it expires and there is no built in way to cancel one. That is the fundamental trade: statelessness means the server does not check anything, which means it cannot un-check it either.
Keep access tokens short, minutes rather than days, and use a refresh token that is stored and can be revoked. If you need instant revocation you need server state, which means you have given up the main advantage of the format.