Skip to content
DDevToolery

18 November 2024 · 7 min read

Decoding a JWT is not verifying it

A decoder shows you what a token claims. Only a key can tell you whether those claims are true.

A JSON Web Token is three base64url segments separated by dots. The first two are JSON — a header and a payload. The third is a signature. Decoding the first two requires no key at all, which is the source of a persistent and occasionally expensive misunderstanding.

What decoding actually does

Base64 is an encoding, not a cipher. Reversing it is arithmetic. When a tool shows you a token's claims, it has done nothing clever and proved nothing: it has simply rendered text that was already readable to anyone holding the token.

header.payload.signature
  ↑        ↑         ↑
  |        |         └── proves the first two parts were not altered
  |        └── the claims: sub, exp, scope, whatever the issuer put there
  └── the algorithm used to sign

The payload is not secret. Never put anything in a JWT that you would not be comfortable showing to the person holding it.

What verification does

Verifying recomputes the signature over the header and payload using a key, then compares it to the signature in the token. If they match, the token was issued by whoever holds that key and has not been modified since.

This requires the key. For HS256 that is a shared secret; for RS256 or ES256 it is the issuer's public key. A browser tool has neither, which is why no honest one claims to verify.

If a website offers to verify your token, it is asking you to paste your signing secret into a web form. That is a worse idea than the problem it solves.

The failure this causes

The dangerous version of this misunderstanding shows up in code. Someone needs the user ID from a token, reaches for a decode function, reads the sub claim, and trusts it:

// Do not do this.
const payload = jwt.decode(token);
if (payload.role === "admin") {
  grantAdminAccess(payload.sub);
}

An attacker does not need to break any cryptography to defeat this. They write their own JSON, set role to admin, base64url-encode it, and attach any signature at all — the code never checks it. The correct call verifies:

// Verify. Throws if the signature does not match.
const payload = jwt.verify(token, publicKey, { algorithms: ["RS256"] });

Always pin the algorithm

Passing an explicit algorithms list matters. Libraries that read the algorithm from the token's own header have historically been vulnerable to two attacks: alg set to none, which asserts the token needs no signature, and an RS256 token resubmitted as HS256 so the library uses the public key as an HMAC secret — and the public key is, by definition, public.

What a decoder is genuinely good for

  • Reading exp and working out whether a 401 is simply an expired token
  • Checking which scopes the issuer actually granted, versus which you requested
  • Confirming aud and iss point where you expect during an integration
  • Seeing kid so you know which key from the JWKS should be used
  • Spotting that a token is far larger than it needs to be because someone put a user profile in it

Where the trust boundary sits

Decode on the client to display something or make a routing decision that does not matter. Verify on the server before any decision that does. The signature is the only part of a JWT that carries authority, and checking it is the only step that requires a secret you should never paste into a browser.

Tools mentioned