~/blog

A JWT is not encrypted, and decoding it is not verifying it

published

#security#jwt#authentication

The word TOKEN above a segmented bar that ends in a glowing pink padlock, on a dark background
Generated illustration

TL;DR

A JWT is three base64url strings joined by dots. The first two — header and payload — are plain JSON that anyone can read. Decoding them takes no key and proves nothing. Only verifying the third segment, the signature, against a key tells you the token is authentic and untampered. Confusing decode with verify is how the alg: none and algorithm-confusion attacks work.

The structure

A token looks like xxxxx.yyyyy.zzzzz:

base64url is base64 with +// swapped for -/_ and padding stripped (RFC 7515 §2). It is an encoding, not encryption. You can read the first two parts in three lines:

const [header, payload] = token.split('.');
const decode = (s) =>
  JSON.parse(atob(s.replace(/-/g, '+').replace(/_/g, '/')));

console.log(decode(header)); // { alg: 'HS256', typ: 'JWT' }
console.log(decode(payload)); // { sub: '123', role: 'admin', exp: 1718000000 }

No key, no library, no permission. The JWT decoder does exactly this, locally — so never put a secret in a JWT payload expecting it to be hidden.

Decode is not verify

OperationNeeds a key?What it proves
DecodeNoWhat the token claims — nothing about authenticity
VerifyYesThe signature matches header.payload, so the issuer signed it and nobody altered it

Verification recomputes the signature over header.payload using the algorithm and key, then compares it to zzzzz. If you decode a token, read role: "admin", and trust it without verifying, an attacker just edits the payload to say admin and re-encodes it — the unsigned read accepts it. Authorization decisions must come from a verified token, server-side, every time.

The alg:none attack

RFC 7519 defines an algorithm literally named none — “unsecured JWT”, no signature. It exists for cases where integrity is guaranteed by other means. The attack: take a valid token, change the header to {"alg":"none"}, keep your tampered payload, and send an empty signature:

{"alg":"none","typ":"JWT"}.{"sub":"123","role":"admin"}.

A library that reads alg from the attacker-supplied header and dispatches verification accordingly will see none, skip the signature check, and accept the forged token. This was a real, widespread library bug disclosed in 2015 (Auth0: critical vulnerabilities in JSON Web Token libraries) and it still resurfaces in hand-rolled verifiers.

Algorithm confusion: RS256 → HS256

A subtler variant. RS256 uses an asymmetric keypair: the server signs with a private key and verifies with a public key (which is, by design, public). HS256 is symmetric: the same secret signs and verifies. If a verifier picks the algorithm from the token header, an attacker can:

  1. Take the server’s public RSA key (often published at a JWKS endpoint).
  2. Forge a token with header {"alg":"HS256"} and a malicious payload.
  3. Sign it with HMAC-SHA256 using the public key string as the HMAC secret.

The server, told HS256, verifies HMAC using that same public key as the secret — and it matches. The public key was never meant to be a secret, so the attacker had everything needed. RFC 8725 (JWT Best Current Practices) names this explicitly.

What to do

Caveats

References