Decoding a JWT Is Not Verifying It
Anyone holding a JSON Web Token can read every claim inside it in about two seconds, with no key and no permission. That surprises a lot of teams — and the confusion between reading a token and trusting it is behind some of the most quietly devastating authentication bugs in production today.
The short version
- A JWT is signed, not encrypted. Base64url is an encoding, not a lock — treat every claim as publicly readable.
- Verification means checking the signature and pinning the algorithm, then validating
iss,aud,expandnbf. - Two classic forgeries —
alg: noneand RS256→HS256 confusion — both work by getting your server to take the attacker's word for which algorithm to use. expis in seconds, not milliseconds. Getting this wrong either expires every token instantly or never.
What is actually inside a token
A JWT in its common form is a JWS Compact Serialization — three Base64url-encoded segments joined by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 <- header
.eyJzdWIiOiIxMjMiLCJyb2xlIjoiYWRtaW4ifQ <- payload
.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk <- signature
Base64url is a near-identical cousin of Base64: + becomes -, / becomes _, and the trailing = padding is stripped so the token survives being dropped into a URL. That is the entire transformation. Decoding it requires no secret, no key, and no cooperation from the issuer.
This is worth sitting with, because it has a direct consequence: a JWT payload is a postcard, not an envelope. If you put an internal user ID, an email address, a subscription tier, or a feature flag in a token, assume the person holding that token can read it — and assume they will.
Signing guarantees integrity (nobody altered it) and authenticity (a holder of the key created it). It guarantees nothing about confidentiality. If you genuinely need the contents hidden from the bearer, you need JWE (encrypted tokens, RFC 7516) or — far more often the right answer — an opaque random session ID that means nothing outside your database.
Two different operations that look the same
Almost every JWT library exposes both, and they are frequently mixed up because the output looks identical:
| Operation | Needs a key? | What it proves | Typical API |
|---|---|---|---|
| Decode | No | Nothing. It only tells you what the token claims. | jwt.decode(), atob() |
| Verify | Yes | The signature matches, so the claims are authentic and unmodified. | jwt.verify(), jwtVerify() |
Both hand you back the same tidy object of claims. The difference is invisible in your logs, invisible in your tests, and catastrophic in production. In Node's widely used jsonwebtoken package, jwt.decode(token) and jwt.verify(token, key) differ by one word at the call site and by everything in their security properties.
The rule is blunt: never make an authorisation decision from a decoded token. Decoding is for debugging, for reading a token you already verified, and for inspecting one in a tool. Authorisation requires verification.
Attack one: alg: none
RFC 7518 defines an algorithm literally called none, intended for cases where a JWS is already protected by some other means. A token using it has an empty third segment:
{ "alg": "none", "typ": "JWT" }
{ "sub": "123", "role": "admin" }
(no signature)
The attack is exactly as simple as it sounds. Take a legitimate token, rewrite the payload to say "role": "admin", set the header algorithm to none, drop the signature, and send it. A server that reads alg from the header and dispatches on it — which is the naive reading of the spec — will conclude that no signature is required and cheerfully accept the forgery.
Mature libraries now refuse none unless you explicitly opt in, but this keeps reappearing in hand-rolled verification code and in older dependency versions. The defence is not "check that alg isn't none" — it is to stop taking the algorithm from the token at all.
Attack two: algorithm confusion (RS256 → HS256)
This one is subtler and considerably more elegant, and it is the reason the previous section says to pin the algorithm rather than blocklist one value.
RS256 is asymmetric: the issuer signs with a private key, and you verify with the matching public key. The public key is, by design, not a secret — it is often served openly at a JWKS endpoint. HS256 is symmetric: the same shared secret both signs and verifies.
Now consider verification code that fetches the public key and calls:
// Dangerous: the algorithm comes from attacker-controlled input
jwt.verify(token, publicKeyPem);
If the library infers the algorithm from the token's own header, an attacker can:
- Download your public key — it is public.
- Craft a token with
{"alg": "HS256"}and whatever claims they like. - Sign it with HMAC-SHA256 using the public key bytes as the shared secret.
- Send it. Your server sees
HS256, reaches for the key material it was given — the public key — and uses it as an HMAC secret. The signature checks out.
The server has been tricked into treating a public value as a private one. Nothing was cracked; the key material was simply used in the wrong role.
Always pass an explicit allowlist of acceptable algorithms, and make the key type match:
jwt.verify(token, publicKeyPem, { algorithms: ['RS256'] });
Now a token announcing none or HS256 is rejected before any key is touched. The token no longer gets a vote in how it is validated.
Signature valid is not the same as token acceptable
A verified signature tells you the token is authentic and unmodified. It does not tell you the token is for you, for now, or still good. Those are separate checks against the registered claims in RFC 7519 §4.1:
| Claim | Meaning | Why it matters |
|---|---|---|
iss | Issuer | Pin it. Otherwise a validly signed token from a different tenant or environment may be accepted. |
aud | Audience | Stops a token minted for service A being replayed against service B that trusts the same issuer. |
exp | Expiry | Without it, a leaked token is valid forever. |
nbf | Not before | Rejects tokens presented ahead of their validity window. |
iat | Issued at | Useful for "reauthenticate if older than N" policies. |
sub | Subject | The principal. Confirm it still exists and is still active. |
jti | Token ID | The hook for replay detection and targeted revocation. |
The seconds-versus-milliseconds trap
exp, nbf and iat are NumericDate values: seconds since the Unix epoch. JavaScript's Date.now() returns milliseconds. Mixing them is a rite of passage:
// Wrong - expiry roughly 52,000 years from now
{ exp: Date.now() + 3600 }
// Wrong - already expired, by about 56 years
{ exp: Math.floor(Date.now() / 1000) - 1_800_000_000 }
// Right
{ exp: Math.floor(Date.now() / 1000) + 3600 }
The failure mode is nastily asymmetric. Multiply by a thousand too many and tokens never expire, which is a silent security hole nobody notices. Divide wrongly and every token is dead on arrival, which is loud and gets fixed in ten minutes. The dangerous bug is the quiet one.
Allow a little clock skew
Two servers are never in perfect agreement. If the issuer's clock is 800ms ahead, a token can arrive with an nbf microscopically in the future and be rejected for no good reason. Most libraries expose a leeway or clock-tolerance setting; 30–60 seconds is a common, sane figure. Do not set it to hours — that is quietly extending the lifetime of every token you issue.
Header fields that reach out into the world
The JOSE header can carry more than alg, and several fields are attacker-controlled input that some implementations act on:
kid(key ID) — tells you which key to use. If you interpolate it into a file path you inherit path traversal; into SQL, injection. Treat it as an untrusted lookup key against a fixed set, never as a location.jku/x5u— URLs pointing at key sets. An implementation that fetches whatever URL the token supplies has handed the attacker both SSRF and full key control. Only ever fetch from origins you have allowlisted.jwk— an embedded public key. Accepting this means accepting "here is the key, please verify me against it," which is self-certification. Ignore it.
A helpful framing: everything in the header is data the attacker wrote. It can tell you which of your trusted keys to check against. It must never tell you what to trust.
Inspect and verify a token in your browser
Layerbit's JWT Decoder splits any token into header, payload and signature, flags the alg in use, and can verify HMAC and RSA signatures locally using the Web Crypto API. Nothing is uploaded — parsing and verification happen entirely in the page.
The part JWTs are genuinely bad at
A verified JWT is a self-contained assertion. That is the whole appeal: your API can authenticate a request without a round trip to a session store. The cost is that you have also given up the ability to change your mind.
When a user logs out, is suspended, or has their role downgraded, any token already issued keeps verifying perfectly until exp passes. There is no server-side session to delete. The usual mitigations, in rough order of how often they are the right call:
- Short-lived access tokens plus refresh tokens. Access tokens live 5–15 minutes; the long-lived refresh token is a revocable server-side record. Damage from a leaked access token is time-boxed, and you keep a real revocation point.
- A
jtidenylist. Keep revoked token IDs in fast storage until their natural expiry. This reintroduces a lookup, but only for the exceptional case. - A per-user token version. Store an integer against the user, embed it as a claim, bump it on logout or privilege change, and reject tokens whose version is stale. One cheap read, invalidates every outstanding token for that user at once.
If you find yourself checking a database on every single request anyway, that is worth noticing honestly: you have paid the complexity of JWTs and kept the cost of sessions. Plain opaque session tokens may simply be the better design for your system.
Where the token lives in a browser
There is no option here without a trade-off, and anyone who tells you otherwise is selling something:
| Storage | Exposed to | Notes |
|---|---|---|
localStorage | Any XSS on the origin | Readable by any script that runs on your page, including a compromised dependency. |
Non-httpOnly cookie | Any XSS on the origin | Same exposure as above, with cookies' automatic-sending behaviour on top. |
httpOnly cookie | CSRF, if unguarded | Unreadable by JavaScript. Needs SameSite and/or CSRF tokens. |
| In-memory only | Nothing persistent | Safest; lost on refresh, so it needs a refresh-token flow behind it. |
For most applications, an httpOnly, Secure, SameSite=Lax cookie carrying a short-lived token, with CSRF protection on state-changing requests, is the pragmatic default. The key realisation is that "XSS can steal the token" and "CSRF can use the token" are different problems, and choosing a storage location means choosing which one you are going to defend against deliberately rather than accidentally.
A verification checklist
Before a request reaches your business logic, confirm every one of these:
- Signature verified with a key you selected — not one the token named.
- Algorithm matched against an explicit allowlist.
nonenever appears on it. - Key type is right for the algorithm — an RSA public key can never be an HMAC secret.
issequals your expected issuer, compared exactly.audincludes this specific service.expin the future andnbfin the past, with modest clock leeway.subresolves to a principal that still exists and is still permitted.- Revocation state consulted, whichever mechanism you chose.
- Token type is what you expect — a refresh token must never be accepted as an access token.
Steps one to three defeat forgery. Four to seven defeat misuse of genuine tokens. Eight and nine defeat replay of tokens that were genuine once and no longer should be. Skipping any tier leaves a category of attack fully open, which is why "we verify the signature" is a beginning rather than an answer.
Reading a token is a debugging tool, not a security boundary
Paste a token into a decoder and you will see its claims laid out clearly — which is exactly the point of a decoder, and exactly why it proves nothing. That readability is a feature for the engineer debugging a login flow at midnight, and a feature for the attacker reading a support ticket that had a token pasted into it.
Hold both facts at once: decoding is free and available to everyone, and verification is the only thing standing between a string of Base64 and a trusted identity. Every JWT bug worth its name lives in the gap between those two sentences.