Reading a JWT safely: why decoding is not the same as verifying
JWT structure, base64url decoding, why a decoded token proves nothing, and the alg:none and key-confusion attacks to know about.
Published 2026-09-25
Three parts, dot-separated
A JSON Web Token is a compact string with three parts joined by dots: header.payload.signature. Per RFC 7519, the header and payload are each a JSON object, base64url-encoded (the URL-safe Base64 alphabet, without padding); the signature is raw bytes from an HMAC or a signature algorithm, also base64url-encoded, computed over the header and payload together.
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0IiwiZXhwIjoxNzU4NzY4MDAwfQ.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
Splitting on . and base64url-decoding the first two segments gives you plain JSON:
Header:
{ "alg": "HS256" }
Payload:
{ "sub": "1234", "exp": 1758768000 }
Decoding both is exactly what this hub's JWT decode tool does — split on ., base64url-decode the first two parts (the same underlying operation as the Base64 decode tool, just with the URL-safe alphabet and no padding). The third segment, the signature, is not meant to be human-readable — decoding it just gives you binary bytes, not JSON.
Decoding proves nothing about authenticity
This is the point that causes real bugs: anyone can decode a JWT, because base64url is not a secret — it's a text encoding, reversible by definition, with no key involved. Reading the claims out of a JWT tells you what the token claims, not that those claims are true. A token could be entirely fabricated by an attacker who typed out arbitrary JSON, base64url-encoded it, and stuck a . between the parts — decoding that forged token would still show you perfectly readable JSON.
Verifying is a separate operation: recomputing the signature over the header and payload using the correct algorithm and the correct key (a shared HMAC secret, or a public key for RSA/ECDSA), and checking it matches the signature segment. Only a passing verification means the token was issued by someone holding that key and hasn't been altered since. Never write code that reads a claim out of a decoded-but-unverified JWT and uses it for an authorization decision — that's equivalent to trusting user input directly, because for an attacker with no key at all, it is exactly that.
The alg:none attack
RFC 8725, the JWT security best-practices document, singles out a specific historical vulnerability: the JWT spec allows an alg value of "none", meaning no signature at all. Some early JWT libraries, when they saw "alg": "none" in the header, would skip signature verification entirely and treat the token as valid. An attacker who can influence which algorithm your verifier accepts can craft a token with alg: none, an empty signature segment, and any payload they want — and if your library trusts the header's own claim about its algorithm, that forged token passes.
The fix, per RFC 8725 §3.1, is on the verifier's side: the caller must specify which algorithms it accepts, and the library must never use any other algorithm — including none — regardless of what the token's own header claims. Modern JWT libraries require you to pass an explicit allow-list of algorithms when verifying; if a library lets you verify without specifying one, treat that as a red flag.
Key confusion: RS256 vs HS256
A subtler variant of the same root problem, also documented in RFC 8725 §2.1: RS256 signs with a private RSA key and verifies with the corresponding public RSA key — which, being public, is often exposed (in a JWKS endpoint, a config file, or even embedded in client-side code). HS256 signs and verifies with the same shared secret. If a verifier is told "trust whatever algorithm the header says" instead of "verify only with RS256," an attacker can take a legitimate RS256 token's public key, use it as the HMAC secret for a forged HS256 token, and set "alg": "HS256" in the header. A verifier that blindly follows the header's algorithm will use that public key as an HMAC secret — something it was never meant to be — and the forged signature checks out.
Both attacks share the same root cause and the same fix: pin the algorithm on the verifying side, tied to a specific key, and never let the token's own header dictate which algorithm or which key gets used to check it.
Expiry and other claims worth checking
A verified signature confirms the token wasn't tampered with; it says nothing about whether the token should still be honored right now. JWTs carry standard timing claims for that:
exp(expiration time) — reject the token if the current time is past this.nbf(not before) — reject the token if the current time is before this.iat(issued at) — when the token was created; useful for auditing or enforcing a maximum token age.
{ "sub": "1234", "exp": 1758768000, "iat": 1758764400 }
These are Unix timestamps (seconds since epoch), and a JWT library's verify call typically checks exp/nbf automatically — but only if you're calling verify at all, not just decode. RFC 8725 also recommends validating iss (issuer) and aud (audience) when a token could plausibly have come from, or be intended for, more than one source — otherwise a token correctly signed for a different service can be replayed against yours.
A practical checklist
- Decode to read claims during debugging — that's what the JWT decode tool is for, and it's safe because it makes no security claim about the token.
- Never make an authorization decision from a decoded-but-unverified token in application code — always call your library's verify function with an explicit key and algorithm.
- Pin the accepted algorithm(s) explicitly on the verifying side; never let the token's own
algheader decide. - Check
expandnbf— a verified signature on an expired token is still a token you should reject. - If a library's "verify" function doesn't require you to name an algorithm, that's worth investigating before you trust it in production.