Decoding a JWT Is Not the Same as Verifying It
Every JWT bug in production reduces to the same mistake: trusting a decoded token without verifying its signature. The difference, the consequences, and how to do it right.
A JWT looks like one string. It’s actually three things welded together with periods. Knowing the difference between reading those three things and verifying them is the line between auth-as-intended and security-as-disaster.
This post is the difference, in plain terms, with the production consequences of getting it wrong.
What’s in a JWT, exactly
A JWT (JSON Web Token, RFC 7519) is a string of three Base64URL-encoded sections separated by dots:
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyXzEyMyIsImV4cCI6MTk5OTk5OTk5OX0.signature
└────── header ──────┘└──────────── payload ────────────┘└─ signature ─┘
- Header: a small JSON object with the signing algorithm. Decodes to something like
{"alg":"HS256","typ":"JWT"}. - Payload (a.k.a. claims): JSON with whatever the issuer wants — user ID, expiry, audience, custom data.
- Signature: cryptographic bytes computed from
Base64URL(header) + "." + Base64URL(payload)using the algorithm and key specified in the header.
The first two parts are not encrypted. They’re encoded. Anyone who has the JWT can read them, instantly, for free.
Try it now. Take any JWT and paste into the JWT Decoder. The header and payload appear immediately. No key required. No password. Nothing.
Decoding ≠ verifying
Decoding means: split on dots, Base64URL-decode each part, parse the JSON. Anyone can do this. It tells you what the token claims to be.
Verifying means: re-compute the signature from the header+payload using the issuer’s key, and check that it matches the signature in the token. This requires the key. It tells you that the token was actually issued by who it says, and the contents haven’t been changed.
The vital insight: decoded data is just a claim, not a fact.
A decoded JWT might say {"sub": "admin", "role": "superuser"}. Without verification, you have no idea if:
- The issuer actually granted that role
- Anyone could have hand-crafted this token and pasted in any claims they wanted
- The token expired three years ago
A verified JWT, in contrast, proves the issuer signed exactly these claims at the time of issuance, and the signature hasn’t been tampered with.
The production failure mode
Every “we got hacked through our JWT auth” story I’ve read traces back to one of these:
Failure mode 1: The decode-and-trust
// 🚫 Catastrophically wrong
const payload = JSON.parse(atob(token.split('.')[1]));
if (payload.role === 'admin') {
// grant admin
}
Anyone can craft a token with {"role": "admin"} and base64-encode it. No signature is checked. The “auth” is theater.
This pattern shows up because:
- Devs use jwt-decoder tools (like ours!) for debugging, then accidentally translate the same logic into server code.
- Frameworks sometimes have a
jwt.decode()method (no verify) and ajwt.verify()method (with key). People grab the convenient one. - The token “works” — it round-trips, the right fields appear — so it ships.
Failure mode 2: The “alg: none” attack
// 🚫 Naive verifier
const verified = jwt.verify(token, key, { algorithms: undefined });
Some JWT libraries default to allowing the special alg: "none" algorithm, which means no signature is required. An attacker crafts a JWT with {"alg":"none"} and an empty signature, and the verifier accepts it.
The fix: always specify allowed algorithms explicitly:
// ✅ Correct
const verified = jwt.verify(token, key, { algorithms: ['HS256'] });
Most modern libraries (jsonwebtoken 8+, jose) reject alg: none by default, but check yours.
Failure mode 3: HMAC vs RSA key confusion
If your verifier accepts both symmetric (HS*) and asymmetric (RS*, ES*) algorithms with the same key lookup, an attacker can:
- Get your RSA public key (it’s public — they’re in your
.well-known/jwks.json). - Craft a JWT with
{"alg":"HS256"}and sign it with your public key as the HMAC secret. - Your verifier looks up “the key for HS256” — gets the public key — uses it as HMAC. Signature checks out. Token accepted.
The fix: lock the algorithm per issuer. If your server expects RS256, only accept RS256.
Failure mode 4: Expiry not checked
// 🚫 Verifier ignores exp claim
jwt.verify(token, key, { algorithms: ['HS256'] });
// proceed without checking payload.exp
Some libraries verify the signature but don’t enforce the exp (expiration) claim unless you ask. A token signed two years ago is “valid” forever.
The fix: most libraries have ignoreExpiration: false as default but verify yours. Always check exp. Most also support clockTolerance for small clock skew between issuer and verifier — keep this small (30–60 seconds).
Failure mode 5: Trusting the issuer URL from the token
JWS supports headers like jku (JWK Set URL) or x5u (X.509 URL) that tell the verifier where to fetch the key from. If your verifier blindly fetches whatever URL the token says, an attacker controls the URL → controls the key → controls the signature.
The fix: never trust jku or x5u from the token. Hardcode (or strict-allowlist) which JWKS endpoint you fetch keys from.
How to verify correctly
In Node.js with jsonwebtoken:
import jwt from 'jsonwebtoken';
try {
const claims = jwt.verify(token, publicKey, {
algorithms: ['RS256'], // explicit, no fallback
issuer: 'https://issuer.example.com', // verify iss claim
audience: 'my-api', // verify aud claim
clockTolerance: 30, // 30 sec clock skew
});
// claims is now trustworthy
} catch (err) {
// signature invalid, expired, wrong issuer, wrong audience, etc.
}
Note what verification also does in modern libraries:
- Validates the algorithm matches expectations
- Checks
exp(expiration) andnbf(not-before) - Validates
iss(issuer) andaud(audience) if you supply expected values - Computes signature with the correct algorithm
In Python with pyjwt:
import jwt
claims = jwt.decode(
token,
public_key,
algorithms=['RS256'],
issuer='https://issuer.example.com',
audience='my-api',
)
(Python’s decode() with a key actually verifies. The verify=False parameter disables it. Don’t pass verify=False outside of debugging.)
When decoding-without-verifying is OK
There are exactly two cases where decoding without verifying makes sense:
-
You’re a debugging tool. Xerobit’s JWT Decoder shows you what’s in a token without needing the signing key. You should never paste your signing key into a third-party web tool. The trade-off: you see the claims but can’t prove they’re authentic — and the tool labels this clearly.
-
You’re inspecting your own freshly-issued token. When debugging your own auth flow, you can decode tokens you just issued because you trust the issuance step. This is for development; never extends to production.
In every other case, decode without verify = no security. Period.
A simple mental model
A JWT is a paper letter. Decoding is reading the letter. Verifying is checking the wax seal.
You can read any letter that lands in your hands. The text is plain and visible. But that text is just a claim — anyone could have written it.
The seal proves who wrote it. To verify the seal, you need the writer’s seal-stamp (the signing key). Without that, the seal is just decoration.
A well-crafted forgery looks identical to a real letter. The only difference is the seal. Skipping the verify step is reading a forged letter and acting on it.
Tools for the job
- JWT Decoder — for debugging your own tokens. Decodes; never verifies (intentionally).
- Server-side:
jsonwebtoken,jose, orpassport-jwt(Node);pyjwt(Python);jose-jwt(.NET);JJWT(Java). - For machine-to-machine OAuth flows where the server needs to fetch keys, use a JWKS-aware library that caches and respects
kidheaders correctly.
The one rule
Never trust the contents of a JWT until you’ve verified the signature with the issuer’s key.
That’s it. Internalize that, and 95% of JWT security problems disappear.
Further reading
- JWT Security Checklist for 2026 — the full pre-deploy checklist
- Base64: How It Actually Works Under the Hood — the encoding JWTs are built on
- RFC 7519 — JSON Web Token (JWT) — the spec itself
- RFC 7515 — JSON Web Signature (JWS) — the signing layer
Related posts
- JWT Security Checklist for 2026 — Twelve checks every JWT implementation should pass before shipping. The actual c…
- Base64: How It Actually Works Under the Hood — Base64 is everywhere — in JWTs, data URLs, email attachments. This is the byte-l…
Related tool
Decode and inspect JSON Web Tokens. Local-only — tokens never leave your browser.
Written by Mian Ali Khalid. Part of the Encoding & Crypto pillar.