← blog

A JWT You Can Read Isn't a JWT You Can Trust

Paste a JWT into a decoder and you can read every claim inside it — the user id, the roles, the expiry. That is not a leak. It is how JWTs are designed to work. The real hole opens when someone treats being able to read a token as proof they can trust it.

A JSON Web Token (RFC 7519) is a compact, signed envelope for some JSON. It turns up as a session token, an API credential, an OAuth/OIDC id token. Three base64url chunks joined by dots: header.payload.signature. Servers like it because it is self-contained — the claims ride along with the request, so a service can check them without a database round-trip on every call.

The anatomy, part by part

The header is JSON describing the token: its type and, importantly, the signing algorithm in alg — for example HS256 or RS256. The payload is JSON holding the claims: who the subject is (sub), who issued it (iss), who it is for (aud), and when it stops being valid (exp). The signature is the only part that proves the first two were not altered.

Header and payload are each serialized to JSON and then base64url-encoded — the URL-safe Base64 variant from RFC 4648 that swaps +/ for -_ and drops the padding. The signature is computed over base64url(header) + "." + base64url(payload) using the algorithm named in the header, then base64url-encoded too.

base64url is encoding, not encryption

This is the single most useful thing to internalize. Base64 is a binary-to-text encoding. It scrambles nothing and hides nothing. There is no key. Anyone holding the token can split on the dots and decode the payload back into plain JSON in one line.

echo "$JWT" | cut -d. -f2 | base64 -d

A signed JWT protects integrity, not confidentiality. As jwt.io puts it, the claims are "readable by anyone." So treat the payload as public. Do not put a password, a private key, a card number, or anything you would not paste into a URL inside it. If the contents genuinely must stay secret, you need an encrypted token (JWE) — a different mechanism, and a signed JWT alone is not it.

Signed means tamper-evident. It does not mean private. Anyone with the token can read it.

Where trust actually breaks

Decoding tells you what a token claims. Verifying tells you whether to believe it — recomputing the signature with the right key, then checking the claims. The well-known failures all live in the gap between those two steps.

alg=none

The spec allows an unsecured JWT with "alg": "none" and an empty signature. A library that honors the header's choice of algorithm will happily "verify" a token that has no signature at all. An attacker edits the payload, sets alg to none, drops the third segment, and walks in. Naive none blocklists get bypassed with casing tricks like NoNe. The fix is to pin the expected algorithm server-side and never let the token pick it. The OWASP Web Security Testing Guide walks through exactly this.

RS256-to-HS256 confusion

With RS256 the server verifies using an RSA public key — which, being public, is often easy to obtain. If the verifier trusts the header's alg, an attacker switches it to the symmetric HS256 and signs with the public key bytes as the HMAC secret. The server, expecting RSA but obeying the header, runs HMAC verification with that same public key, and the forged signature checks out. PortSwigger's Web Security Academy has a clean breakdown of this algorithm-confusion class.

Skipping exp and nbf

A correct signature on a stale token still means nothing if you do not check time. RFC 7519 says a token MUST NOT be accepted on or after exp, or before nbf. Verifying the signature but ignoring expiry turns a leaked token into a permanent one. While you are there, check iss and aud too — a token minted for another service or another tenant can carry a perfectly valid signature.

Our decoder is a reader, not a verifier

exl.ink's jwt decoder splits the token, base64url-decodes the header and payload, and shows you the claims — labeling exp and nbf as human dates so you can see at a glance whether a token is stale. It runs entirely in your browser; the token is never sent to us. That is deliberate, and it is also a confession about scope: we never ask for your secret or public key, which means we cannot — and do not — verify the signature.

Use it to inspect a token, debug a claim, confirm an expiry. Do not use a decoder as evidence that a token is trustworthy — that decision belongs to whatever holds the key, verifying the signature against a pinned algorithm and then checking the claims. A token you can read is just a token you can read.

Further reading