JWT decoded ≠ JWT verified
On this page
A JWT is three base64url-encoded parts joined by dots:
<header>.<payload>.<signature>. Anyone — including you, including an
attacker — can decode the first two parts in milliseconds. There is no
key required, no API call, nothing. The header and payload are JSON
blobs in plain sight.
This is why “decoding” a JWT means almost nothing. A decoder shows you what the token claims, not whether the token is valid. Treating those two as the same thing is one of the most common JWT bugs in production code, and it’s the bug that cost Auth0 a CVE in 2020 and a half-dozen smaller libraries since.
This guide pulls the two apart, with a focus on what verification actually does that decoding doesn’t.
What “decode” means
Take a JWT like:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Decoding it means:
- Split on
.into three pieces. - Base64url-decode each piece.
- Parse pieces 1 and 2 as JSON.
- Show the result.
Try this on any base64 decoder. The result is something like:
{ "alg": "HS256", "typ": "JWT" }
{ "sub": "1234567890", "name": "John Doe", "iat": 1516239022 }
That’s it. Pure transformation. No validation. The signature (third part) is opaque bytes — decoding doesn’t touch it.
What this tells you:
- What the token claims to contain — the user ID, name, expiry, whatever the issuer put in.
- Which algorithm the issuer claims they used —
alg: HS256in the example.
What it does not tell you:
- Whether the signature is valid for those claims. An attacker can swap any payload they like and the decode will succeed.
- Whether the issuer is who you expect —
iss: "evil.com"decodes fine. - Whether the token has expired.
expdecodes; nobody checks it. - Whether the algorithm is one you accept.
alg: "none"decodes too.
What “verify” means
Verification re-computes the signature using a key you control, and checks the recomputed signature byte-for-byte against the third part of the token. If they match, the token came from the holder of the key (or its public counterpart, for asymmetric algorithms) and hasn’t been tampered with since.
The algorithm depends on what alg is supposed to be — but you don’t
trust the token’s alg field. You decide, server-side, which algorithm
the verifier expects. More on this below.
For HS256 (HMAC-SHA-256, the most common):
import { createHmac, timingSafeEqual } from "node:crypto";
const [headerB64, payloadB64, providedSig] = token.split(".");
const data = `${headerB64}.${payloadB64}`;
const expectedSig = createHmac("sha256", SECRET).update(data).digest("base64url");
const provided = Buffer.from(providedSig, "base64url");
const expected = Buffer.from(expectedSig, "base64url");
if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) {
throw new Error("invalid signature");
}
For RS256 (RSA-SHA-256, used by Auth0, Google, Apple, etc.) the verifier
fetches the issuer’s public key (usually from a JWKS endpoint), runs RSA
verify on <header>.<payload> against the signature.
If the signature checks out, you can finally trust the claims. Now — and only now — start checking the rest.
The five things a real verifier checks
A robust verifier does signature and five claim checks. Skipping any of them is a footgun.
alg— match against your allow-list. Rejectnoneand reject any algorithm you didn’t intend to accept. The “alg confusion” class of attack usesalg: HS256against a verifier that was expectingRS256— the verifier ends up using the public key as an HMAC secret. Pin the algorithm.exp(expiration) — the token must not have expired. A small clock-skew tolerance (~30 seconds) is OK. Many libraries skip this ifexpis missing; that’s wrong — reject unsigned-expiry tokens.nbf(not-before) — the token must not be used before this time. Same skew tolerance.iss(issuer) — match against your allow-list. A token issued byhttps://other.com/should not work on your service.aud(audience) — the verifier’s own identifier must be in the token’s audience list. A token issued for service A should not work on service B.
Some applications add jti (JWT ID) for revocation — when a user
logs out, the jti goes into a deny-list and any incoming token with
that jti is rejected. This is the only practical way to invalidate
JWTs before they expire.
Why the confusion?
A few reasons.
Decoders are everywhere; verifiers aren’t. jwt.io, our own JWT decoder, and a thousand others let you paste-and-see in two clicks. Verifiers need a key, which is more friction. So when engineers want to “look at” a JWT, they reach for a decoder — and the muscle memory is “decode = inspect = trust”.
The alg: none footgun is a long-running story. RFC 7519 allows
JWTs with no signature (alg: none). Many libraries used to accept
these by default. An attacker would change a real token’s alg to
none, blank out the signature, and the verifier would happily
re-encode and pass it. CVEs in 2015 (auth0/node-jsonwebtoken),
2020 (Hapi/iron),
2023 (jose-jws),
and quietly several since. Modern libraries default to rejecting
none. But the lesson is: trusting the token’s alg field is the
bug, not just none specifically.
JWT decoders look authoritative. A clean UI showing parsed claims feels like the system told you the token is valid. It didn’t. The system told you what the token claims. The two are different sentences.
Practical takeaway
When you paste a JWT into a decoder:
- Treat the result as input you don’t yet trust. The claims are what the token says about itself.
- If you need to know whether the token is valid, use a verifier — and supply your own secret/JWKS. We have a verify page that runs HMAC verify client-side, so you can paste a token and a key and see the answer without a server round-trip.
- In production code: never accept the token’s
algfield as authoritative. Pin the algorithm in your verifier configuration.
A decoder is a magnifying glass. It shows you what’s there. It doesn’t tell you whether the box is locked.