JWT Decode vs JWT Verify: What’s the Difference and Why It Matters
Every JWT tutorial says "decode the token." Fewer explain that decoding and verifying are completely different operations — one needs no key and proves nothing, the other checks a cryptographic signature and a set of claims and is the only thing standing between your API and a forged admin token. Confusing the two is not a beginner-only mistake; it is behind some of the most cited authentication CVEs of the last decade. This guide draws the line precisely and walks through the two classic attacks that turn a sloppy verify step into account takeover.
Anatomy of a token
A JWT is three base64url segments joined by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 . eyJzdWIiOiI0MiIsInJvbGUiOiJhZG1pbiJ9 . 3Tc9x...signature
header payload signature
The header names the signing algorithm (for example {"alg":"HS256","typ":"JWT"}). The
payload carries claims. The signature is computed over the first two segments. The critical fact:
base64url is encoding, not encryption. A standard JWS-signed JWT is signed, not
encrypted — encryption is an entirely separate spec (JWE). That has one non-negotiable
consequence.
Decoding is public: never put secrets in the payload
Decoding is just base64url-decoding two strings. No key is involved. Anyone who holds the token — a browser extension, a proxy log, an attacker who grabbed it from local storage — can read every claim in the payload without your secret and without your permission. Paste any token into the JWT Decoder and you will see the payload in cleartext instantly.
So the payload is the wrong place for anything sensitive: no passwords, no full credit-card numbers, no internal flags you would not hand to the user directly. Base64url is the same reversible encoding covered in the hashing vs encryption guide — it changes the representation of data, never its confidentiality. Assume the payload is world-readable, because it is.
Verifying checks the signature AND the claims
Verification is the step that produces trust. It recomputes the signature over
header.payload using your secret (HS256) or the issuer's public key (RS256) and rejects the
token if it does not match — and then it validates the claims that decoding happily ignores:
exp— expiry; reject if the token is past itnbf— not-before; reject if the token is not yet validiss— issuer; must match the party you trust to mint tokensaud— audience; the token must be intended for your service, not a sibling API that happens to share a signing key
A signature-valid token with the wrong aud is still a rejection. Skipping the audience
check is how a token minted for a low-privilege service gets replayed against a high-privilege one. The
signature underneath is typically an HMAC (HS256) or an RSA/ECDSA signature (RS256/ES256); we cover how
HMAC keys a hash in the hashing vs encryption guide.
Decoding without verifying trusts attacker-controlled data. An attacker can flip
"role":"user" to "role":"admin", push exp years into the future,
or swap the sub to impersonate someone — and it all still decodes into valid JSON. Only the
signature check catches it.
Attack 1: the alg: none bypass
The JWT spec defines an algorithm literally named none — a token with no signature at all,
intended for cases where integrity is guaranteed by another layer. The problem: several popular
libraries historically accepted a token whose header said "alg":"none" and treated
it as valid with an empty signature. An attacker takes a real token, rewrites the payload, sets the
header algorithm to none, strips the signature, and the naive verifier waves it through.
The mitigation is blunt and mandatory: reject none on the server, and only
accept the specific algorithm you expect.
Attack 2: RS256 → HS256 algorithm confusion
This one is subtle and worth internalizing. With RS256, tokens are signed with the issuer's RSA private key and your server verifies them with the corresponding public key — which, being public, is not a secret. With HS256, the same key both signs and verifies (symmetric HMAC).
Now the attack: the attacker changes the header from RS256 to HS256, then
signs a forged token using your RSA public key as the HMAC secret. If your verification
code reads the algorithm from the token and passes the same key parameter to a generic verify
function, the library dutifully runs HMAC-SHA256 with the public key — the exact value the attacker just
used — and the signatures match. The forgery is accepted because the public key was never meant to be
secret, yet the code let the token dictate that it be used as one.
The fix is algorithm pinning: hard-code the expected algorithm server-side (e.g.
algorithms: ["RS256"]) and never let the incoming token choose how it is verified. Treat
the header's alg as an attacker-supplied hint, not an instruction.
Two bugs that pass every happy-path test
Comparing signatures with ==
Signature comparison must be constant-time. A byte-by-byte compare that returns early on the first mismatch leaks, through timing, how many leading bytes were correct — enough, in principle, to forge a signature one byte at a time. Reputable libraries use a constant-time comparison internally; if you ever hand-roll the check, use a constant-time equality function.
Putting milliseconds in exp
exp, nbf, and iat are NumericDate values —
seconds since the Unix epoch, not milliseconds. In JavaScript, Date.now() returns
milliseconds, so writing exp: Date.now() + 3600000 produces a timestamp roughly a thousand
times too large — a token that "expires" tens of thousands of years from now and effectively never dies.
The correct value is Math.floor(Date.now() / 1000) + 3600. This bug never shows up in
testing because the token is always valid; it only surfaces as an auditor asking why sessions are
immortal.
The revocation trade-off nobody mentions upfront
The selling point of a JWT is that it is stateless — the server verifies the signature
and trusts the claims without a database lookup. The hidden cost is the mirror image: you cannot
revoke a stateless JWT before it expires. If a token is stolen or a user is banned, a valid
signature keeps working until exp passes. There is no "log out everywhere" for a pure JWT.
Teams recover revocation with extra infrastructure — short-lived access tokens (minutes) paired with
refresh tokens, or a server-side denylist of revoked token IDs — but each of those quietly reintroduces
the state the JWT was supposed to eliminate. Choose the pattern deliberately; do not discover this the
day you need to force-logout a compromised account.
Common JWT claims you will see
- sub — subject, typically the user ID
- iss — issuer that minted the token
- aud — intended audience (validate this)
- exp — expiry, in seconds since the epoch
- iat — issued-at time
- nbf — not-before time
Custom claims such as role, email, permissions, or tenant ID are common too — and every one of them is readable by whoever holds the token.
Best practices for working with JWTs
- Decode for inspection and debugging only; make trust decisions on verification alone
- Pin the accepted algorithm server-side; reject
noneand reject unexpected algorithms - Validate
exp,nbf,iss, andaud, not just the signature - Store
exp/nbfin seconds, never milliseconds - Keep secrets out of the payload — it is world-readable
- Plan revocation up front: short expiry plus refresh tokens, or a denylist
Useful ToolzYard tools
Conclusion
Decoding a JWT is a public, keyless read of attacker-controllable JSON — useful for debugging, worthless
as a trust signal. Verification is where security lives: check the signature, pin the algorithm so the
token cannot downgrade itself to none or trick you into HMAC-ing with a public key, and
validate exp, nbf, iss, and aud. Keep secrets out of
the payload, count time in seconds, and decide your revocation story before you ship. The one-line
takeaway: decoded data is readable, but only verified data should be trusted.
Frequently Asked Questions
Why can anyone decode a JWT without a key?
Because the header and payload are base64url-encoded, not encrypted. A standard JWT is signed, not encrypted (encryption is the separate JWE spec), so anyone holding the token can read every claim. Never place secrets in the payload.
What does verification check besides the signature?
A correct verify step also validates the claims: exp (not expired), nbf (already valid), iss (trusted issuer), and aud (intended for your service). A signature-valid token with the wrong audience should still be rejected.
What is the RS256-to-HS256 confusion attack?
The server verifies RS256 with a public key. An attacker switches the header to HS256 and signs the token using that public key as the HMAC secret. A library that takes the algorithm from the token and reuses the same key will accept the forgery. Fix it by pinning the expected algorithm server-side.
Why is my JWT never expiring?
exp is NumericDate — seconds since the Unix epoch, not milliseconds. Passing Date.now() (milliseconds) makes the expiry roughly 1000x too far in the future. Use Math.floor(Date.now() / 1000) plus your lifetime in seconds.
Can I revoke a JWT before it expires?
Not a purely stateless one — a valid signature is accepted until exp passes. To revoke early you need extra infrastructure: short-lived access tokens with refresh tokens, or a server-side denylist of revoked token IDs.