We've established that Base64 isn't encryption and that you can't un-hash a password. JWTs are where both of those facts collide — and where the misunderstanding gets expensive, because this one has a CVE class named after it.
Here's the claim that surprises people the first time: a JWT is not encrypted, and anyone holding one can read every claim inside it without a key, a library, or your permission. That's not a bug. But if you don't know it, you will eventually put something in a token that shouldn't be there.
Three dots, three parts
A JWT is a string with exactly two dots in it:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJuYW1lIjoiQWxpY2UiLCJhZG1pbiI6dHJ1ZX0.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
Split on the dots and you get header, payload, signature. The first two are just Base64url-encoded JSON. Decode part one and it's:
{ "alg": "HS256", "typ": "JWT" }
Part two:
{ "sub": "123", "name": "Alice", "admin": true }
No key involved. No decryption. It's JSON that went through an encoder, and encoding runs backwards for free — that's what encoding is. If a token is sitting in someone's browser storage, in a log file, in a proxy's request dump, or in a screenshot pasted into Slack, every claim in it is readable by whoever has it.
So the rule falls out immediately: never put a secret in a JWT payload. Not a password, not an API key, not a national ID, not "internal_notes". Assume the payload is public, because functionally it is. It's a signed postcard, not a sealed envelope.
Why "Base64url" and not plain Base64
Small detail, real consequences. JWTs live in hostile places — Authorization headers, query strings, cookies, URLs — and standard Base64's alphabet includes +, /, and =. Those characters mean something in a URL: + can become a space, / splits a path, = separates a query param. A token pasted into a link would corrupt itself.
Base64url fixes it by swapping + → -, / → _, and dropping the = padding. Same encoding, URL-safe alphabet. This is why a JWT survives being a query parameter while a raw Base64 blob often doesn't — and it's the same class of problem percent-encoding solves for everything else you stuff into a URL. Two different escaping schemes, one shared reason: some characters are structural, and data that contains them has to get out of the way.
The signature is the only part that matters
The third segment is where the actual security is. It's not a hash of the payload — it's a keyed hash (an HMAC, for HS256) or a digital signature (for RS256), computed over the header and payload together, using a secret only your server knows:
signature = HMAC-SHA256(base64url(header) + "." + base64url(payload), secret)
This is what a hash is for, in the sense from the last article: a tamper-evident fingerprint. An attacker can freely edit the payload — flip "admin": false to "admin": true — and re-encode it. That part is trivial. What they cannot do is produce the matching signature, because that needs the secret. Your server recomputes the HMAC over whatever it received and compares. Mismatch means forged or corrupted, and the token is rejected.
So the token is readable by everyone and forgeable by no one. That's the actual security model, and it's a good one — as long as you check the signature.
The mistake that has a CVE
Which brings us to the trap in the title. Decoding a JWT and verifying a JWT are completely different operations, and it is very easy to write code that does the first while believing it does the second:
// This is NOT authentication.
const payload = JSON.parse(atob(token.split('.')[1]));
if (payload.admin) { grantAdminAccess(); } // 💥
That code reads a claim the attacker wrote and never touches the signature. Anyone can craft that token in ten seconds. The library function you actually want is verify(token, secret), not decode(token) — and in most JWT libraries those sit right next to each other with nearly identical names. That's how this keeps happening.
The historical version of this bug is worse and worth knowing, because it explains a defensive habit you'll see in real code. Early JWT libraries trusted the alg field in the header — a field the attacker controls. Set "alg": "none", send an empty signature, and some libraries said "no algorithm specified, nothing to check, looks valid." Others accepted swapping RS256 (asymmetric) for HS256 (symmetric), tricking the server into verifying with the public key as if it were the shared secret. Both are full authentication bypasses caused by letting the token declare how it should be checked. The fix is to pin the expected algorithm server-side and never read it from the token.
If you want to see the split for yourself, take any non-production token and drop it into a JWT decoder: the header and payload render as readable JSON instantly, while the signature stays an opaque blob. That's the honest picture — the decoder can show you the claims because nothing is protecting them, and it can't tell you whether the token is genuine because that requires the secret it doesn't have. The asymmetry on the screen is the lesson.
What to actually check
A signature check alone isn't enough; a valid signature only proves the token is authentic, not that it's still appropriate. Verify these too:
-
exp(expiration) — a signature never expires on its own. If you don't checkexp, a stolen token works forever. -
iss/aud— is this token from the issuer you trust, and was it minted for your service? Without these, a valid token from a different tenant or app may sail through. -
nbf(not before) — reject tokens not yet valid. - Revocation — JWTs are stateless by design, so you can't "delete" one. That's the real trade-off: no session lookup, but no logout either. Short expiry plus refresh tokens, or a deny-list for the paranoid paths.
The one-sentence version
A JWT is Base64url-encoded JSON that anyone can read plus a signature only your server can produce — so treat the payload as public, never trust a claim you haven't verified with the secret, pin the algorithm instead of believing the header, and remember that decode() answers "what does this say" while only verify() answers "should I believe it."
Top comments (0)