What a JWT actually is
A JWT is three Base64URL segments joined by dots: header.payload.signature. The header
says which algorithm was used, the payload is a JSON object of claims, and the signature is a
cryptographic value computed over the first two segments with a key.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Decode the first two segments and you get readable JSON. That step is just Base64 — no key, no secret, no permission. Anyone who holds the token can read the payload. That is by design: a JWT is signed, not encrypted. The signing key proves who made it; it does not hide what is in it.
The distinction that matters
| Operation | Needs the key? | Proves |
|---|---|---|
| Decode the header and payload | No | Nothing. It is Base64. |
| Verify the signature | Yes | That the token was issued by whoever holds the key, and has not been modified since. |
Check exp, nbf, aud, iss | No, but verify first | Time and audience constraints — meaningful only on an authentic token. |
A decoder is the first row. It is a debugging tool: it tells you what the server would see if the signature checked out. It never tells you whether it does.
Why “we decode it, so it must be valid” is a vulnerability
If your authorization logic trusts a claim it read without verifying the signature, an attacker does
not need the key. They can take any token they were issued, decode it, change
"role":"user" to "role":"admin", re-encode both segments with Base64, and
present it. The signature segment is now garbage — but nothing checked it, so the server reads
role: admin and complies.
This is not hypothetical. It is one of the most common JWT implementation flaws, and it is why
linters ship rules specifically banning decode-without-verify calls in authorization paths. The
mirror-image attack is worse: if the server trusts the alg header, an attacker can set it
to none and drop the signature entirely. A verifier that reads the algorithm from the
token instead of from its own configuration accepts a token nobody signed.
What a correct verifier does
- Pick the algorithm from configuration, never from the token header. If you expect
HS256, reject anything that is not HS256 — including
noneand RS256. - Verify the signature before reading any claim. For HS256 that is an HMAC over
header.payloadwith your secret; for RS256/ES256 it is a public-key check. Use a maintained library — this is not a place to hand-roll a comparison. - Use a constant-time comparison. Byte-by-byte equality leaks timing information that can be used to forge a signature over many attempts.
- Then check the claims:
exp(not expired),nbf(not before),iss(expected issuer),aud(this service), and any application-specific claim. - Reject duplicate claims. Some parsers take the first occurrence of a key and some
the last; a token with two
rolefields can be read differently by two services.
Things people get wrong even after verifying
- Storing a JWT in localStorage. Any script on the page can read it, so a single XSS becomes full account takeover. Prefer an HttpOnly, Secure, SameSite cookie.
- Assuming the payload is private. Emails, user ids, internal flags — all readable by anyone who intercepts or is given the token. Put nothing in a JWT you would not paste in a support ticket.
- Long expiry with no revocation. A JWT is valid until it expires; there is no logout on the server side unless you add a revocation list. Short-lived access tokens plus a refresh token is the usual answer.
- Trusting
kidblindly. The key-id header points at a key in your store. If it is used to build a file path or a URL without validation, it becomes an injection vector. - Base64 confusion. JWT uses Base64URL:
-and_instead of+and/, and no padding. A standard Base64 decoder may fail on a perfectly valid token segment, and a decoder that “fixes” padding silently can produce a different byte string than the verifier saw.
The attacks, concretely
Algorithm confusion
A verifier that trusts the alg header can be tricked. The classic form uses the difference
between HMAC and RSA: if the server expects RS256 (an RSA signature verified with a public key but
signed with a private key) and an attacker sets alg: HS256, a naive verifier will compute
an HMAC using the public key as the secret. The public key is public, so the attacker can
compute the same HMAC and the signature checks out. The token was never signed by the server.
The defence is to fix the algorithm in configuration and reject anything else, including
none. Never let a token choose its own verification method.
The none algorithm
The JWT spec includes alg: none for unsecured tokens. A verifier that honours it accepts a
token with an empty signature segment. Libraries have historically shipped this behaviour on by
default, which is why “allow none” is now a configuration flag that defaults to false — and why you
should confirm the default in whatever library you use rather than assuming it.
Key confusion through kid
The kid (key id) header tells the verifier which key to use. If the value is interpolated
into a file path or a URL to fetch the key, it becomes a path-traversal or SSRF vector — an attacker can
point it at a file or endpoint they control and supply a key that makes their signature valid. Validate
kid against a fixed allow-list of known key ids.
Signature stripping
An attacker removes the signature and presents header.payload.. Any verifier that treats an
absent signature as “nothing to check” accepts it. Require exactly three segments and reject an empty
third.
Claims worth checking, and what each means
| Claim | Meaning | What to check |
|---|---|---|
exp | Expiry, seconds since epoch | Now is before it — with a small clock-skew allowance |
nbf | Not valid before | Now is after it |
iat | Issued at | Not in the future beyond the skew allowance |
iss | Issuer | Exactly the issuer you expect, string comparison |
aud | Audience | This service; a token for another service must be rejected |
sub | Subject — usually the user id | Whatever your authorization rules need |
jti | Unique token id | Against a replay list if you need revocation |
The two that are most often skipped are aud and iss. Without them, a token
issued for a different service, or by a different issuer, is accepted — and in a multi-tenant system
that is exactly the boundary you were relying on.
Clock skew is real
A server whose clock is thirty seconds fast will reject freshly issued tokens as not-yet-valid, and one that is slow will accept expired ones. Every verifier needs a small tolerance — a minute is typical — and every deployment needs its clocks synchronised. A token that “works locally and fails in production” is often a clock difference rather than a code difference.
A related deployment detail: if you run several services that all verify tokens, they must share the same key material and the same algorithm configuration. A service still configured for HS256 while the issuer moved to RS256 will reject every token — and, during the window when someone “fixes” it by accepting both, will be vulnerable to the algorithm-confusion attack above.
Using the decoder safely
The JWT decoder on this site decodes locally, in your browser — the token never leaves your machine, which matters because a token is a credential. Treat what it shows you as claims to be checked, not facts: if you need to know whether a token is genuine, verification has to happen where the key lives, on the server.
A useful habit when debugging: decode the token, then compare the alg in the header
against the algorithm your service is actually configured to accept. A mismatch is worth
investigating before anything else, because it is the shape of the “alg none” attack.