Decoding a JWT Is Not the Same as Verifying One

Key takeaway: A JWT carries claims an attacker can rewrite freely. Only signature verification with a pinned algorithm, plus explicit checks on issuer, audience and expiry, makes those claims trustworthy.
The Structural Misunderstanding
A JWT is three base64url segments: header, payload, signature. The first two are encoded, not encrypted. Anyone holding a token can read every claim in it, and anyone can produce a modified version with different claims.
The signature is the only thing preventing tampering. Libraries frequently expose a decode function alongside a verify function, and the decode function performs no cryptographic check whatsoever. Code that calls decode and reads a role claim from the result has implemented no authentication at all — it has parsed attacker-controlled input.
The Failure Modes That Recur
Algorithm confusion. The token’s own header names its algorithm, and a library that trusts that field lets the attacker choose. Setting it to none produces a token with an empty signature that some implementations accept. Changing RS256 to HS256 invites the library to verify an asymmetric token using the public key as an HMAC secret — and the public key is public. Always pin the expected algorithm in your verification call rather than reading it from the token.
Unchecked expiry. Verifying the signature while ignoring exp means a token issued last year still works. Some libraries validate expiry automatically and others do not, and assuming the wrong one is easy.
Missing audience validation. If several services share a signing key, a token minted for the low-privilege service verifies correctly at the high-privilege one. The signature is genuinely valid; it was simply not issued for that recipient. The aud claim exists for this and must be checked explicitly.
Missing issuer validation. In a federated setup, accepting any correctly signed token without confirming iss allows a token from a different identity provider to authenticate.
Weak HMAC secrets. A short shared secret is subject to offline brute force, since the attacker holds a valid signed token to test candidates against.
| Check | Consequence if omitted |
|---|---|
| Signature verified | Complete authentication bypass |
| Algorithm pinned server-side | Confusion and none attacks |
exp validated |
Tokens never expire |
aud validated |
Cross-service token reuse |
iss validated |
Wrong issuer accepted |
kid resolved against known keys |
Attacker-supplied key material |
The Revocation Problem
A stateless token cannot be withdrawn. Once signed, it remains valid until it expires, so disabling a user account does not stop their existing token from working.
The practical answers all reintroduce some state. Keep access tokens very short-lived — five to fifteen minutes — and require a refresh against a store that can be checked. Maintain a deny list of revoked identifiers, which is small because entries only need to persist until natural expiry. Or include a token version in the user record and reject tokens whose version is stale.
Choosing long expiry to avoid this complexity means accepting that a compromised token is usable for its whole lifetime, which is rarely a defensible trade for anything privileged.
Where to Put Them
Storing a JWT in localStorage exposes it to any script on the page. An HttpOnly cookie protects it from script access at the cost of needing SameSite handling for cross-site requests. The token format does not change that calculation.
The Bottom Line
Never read claims from a decoded token. Verify with a pinned algorithm, validate issuer, audience and expiry explicitly, resolve key identifiers against a known set, and keep lifetimes short enough that the absence of revocation is tolerable.



