Layerbit
Authentication & Security

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, exp and nbf.
  • Two classic forgeries — alg: none and RS256→HS256 confusion — both work by getting your server to take the attacker's word for which algorithm to use.
  • exp is 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.

Never put secrets in a JWT payload

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:

OperationNeeds a key?What it provesTypical 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:

  1. Download your public key — it is public.
  2. Craft a token with {"alg": "HS256"} and whatever claims they like.
  3. Sign it with HMAC-SHA256 using the public key bytes as the shared secret.
  4. 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.

The fix for both attacks is one line

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:

ClaimMeaningWhy it matters
issIssuerPin it. Otherwise a validly signed token from a different tenant or environment may be accepted.
audAudienceStops a token minted for service A being replayed against service B that trusts the same issuer.
expExpiryWithout it, a leaked token is valid forever.
nbfNot beforeRejects tokens presented ahead of their validity window.
iatIssued atUseful for "reauthenticate if older than N" policies.
subSubjectThe principal. Confirm it still exists and is still active.
jtiToken IDThe 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.

Open JWT Decoder

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:

  1. 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.
  2. A jti denylist. Keep revoked token IDs in fast storage until their natural expiry. This reintroduces a lookup, but only for the exceptional case.
  3. 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:

StorageExposed toNotes
localStorageAny XSS on the originReadable by any script that runs on your page, including a compromised dependency.
Non-httpOnly cookieAny XSS on the originSame exposure as above, with cookies' automatic-sending behaviour on top.
httpOnly cookieCSRF, if unguardedUnreadable by JavaScript. Needs SameSite and/or CSRF tokens.
In-memory onlyNothing persistentSafest; 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:

  1. Signature verified with a key you selected — not one the token named.
  2. Algorithm matched against an explicit allowlist. none never appears on it.
  3. Key type is right for the algorithm — an RSA public key can never be an HMAC secret.
  4. iss equals your expected issuer, compared exactly.
  5. aud includes this specific service.
  6. exp in the future and nbf in the past, with modest clock leeway.
  7. sub resolves to a principal that still exists and is still permitted.
  8. Revocation state consulted, whichever mechanism you chose.
  9. 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.