A JWT has three segments: header, payload and signature. The first two are Base64url-encoded JSON. Reading them requires no key, no secret and no permission — which is the first thing worth internalising.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 ← header
.eyJzdWIiOiIxMjM0NTY3ODkwIn0 ← payload
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV... ← signatureEncoded is not encrypted
Base64 is a transport encoding. It exists so binary data can travel through text channels; it provides no confidentiality whatsoever. Anyone holding a JWT can read every claim in it with two lines of code.
That means a token is not a place to put anything you would mind a user seeing. Internal user ids, role names and feature flags are fine. Email addresses, phone numbers, internal hostnames and anything an attacker could use for reconnaissance are not.
If you need the contents hidden rather than merely tamper-evident, the format for that is JWE, which is encrypted and has five segments rather than three. It is much less common, and not interchangeable.
What the signature buys you
The signature covers the header and payload. It proves the token was issued by someone holding the signing key and that nothing has been altered since. It proves nothing about who is presenting it — a stolen token is a perfectly valid token.
Verifying requires the key. That is why no online tool can verify a token for you, and why any tool offering to is asking for the credential that mints tokens for your entire system. Decoding and verifying are different operations, and only one of them can be done in a web page.
alg: none
The original specification permits an algorithm of none, meaning the token is unsigned. It exists for cases where the transport is already trusted. It is also the root of a well-known family of authentication bypasses.
The attack is simple: take a valid token, change alg to none, strip the signature, edit the payload to claim administrator. A server that reads the algorithm out of the token and does what it says will accept it.
// Vulnerable — the token chooses its own verification
jwt.verify(token, key);
// Correct — the server decides, and rejects anything else
jwt.verify(token, key, { algorithms: ['RS256'] });The related confusion attack swaps RS256 for HS256, so a server expecting an asymmetric signature verifies an HMAC using its own public key — which the attacker also has. Pinning the expected algorithm defeats both.
The time claims, and the factor of 1000
RFC 7519 defines iat, nbf and exp as NumericDate: seconds since 1970.
iat— issued at. Useful for age checks, not for expiry.nbf— not before. The token is not valid until this time.exp— expires at. After this, it must be rejected.
Date.now() returns milliseconds. A token built with exp: Date.now() + 3600000 has an expiry a thousand times too large, landing somewhere around the year 56000. It does not error, it does not warn — it simply never expires, and the bug survives until someone asks why a token from last year still authenticates.
Expiry is the only revocation you get
The appeal of JWTs is that a server can validate one without a database lookup. The direct consequence is that a server cannot un-issue one either. Until it expires, a leaked token works.
Short expiry plus a refresh token is the usual answer — access tokens measured in minutes, refresh tokens stored server-side where they can be revoked. A token with no exp claim at all is a permanent credential, and should be treated as one.
A short checklist
- Pin the expected algorithm on verification. Never read it from the token.
- Set
exp, in seconds, and keep it short. - Check
issandaudif you issue tokens for more than one audience. - Keep tokens out of URLs.
- Put nothing in the payload you would not show the user.
- Treat any token you have pasted into an online tool as compromised.
Where to keep one in a browser
This is contested, and the honest summary is that both common answers have a real weakness.
localStorage is readable by any JavaScript on the page. One compromised dependency, one injected script, and the token leaves with it. It is also convenient, which is why it is everywhere.
An httpOnly cookie cannot be read by JavaScript at all, which removes that entire class of theft. It brings CSRF back as a concern, because the browser attaches it automatically — mitigated by SameSite=Lax or Strict, plus Secure.
When a JWT is the wrong tool
The case for a JWT is that a server can validate it without a database round trip. If your architecture does not need that — a single monolith with a session store already in it — an opaque session id is simpler, revocable immediately, and leaks nothing when stolen from a log.
JWTs earn their complexity across service boundaries, where the validating service does not share a session store with the issuing one. Inside one application they are frequently a stateless answer to a problem nobody had, and they trade instant revocation for it.
JWT DecoderDecodes header and payload in your browser, converts the time claims to real dates and flags alg none and millisecond expiries. It does not verify signatures, and says so.