A JSON Web Token looks like an opaque wall of characters, but most of it is plain text anyone can read. The dangerous confusion is between reading a token and trusting it.
A JSON Web Token (JWT) is the string that sits behind most modern login sessions and API authentication. You have seen it: a long run of letters, numbers, dashes and underscores with two dots in it, arriving in an Authorization: Bearer ... header. It looks encrypted. It looks like something only the server could possibly read. Both of those impressions are wrong, and the gap between what a JWT looks like and what it actually is causes a specific, recurring class of security bug.
The single most important idea in this article: decoding a JWT is not the same as verifying it. Anyone can decode one. Only the holder of the right key can verify one. Confusing the two is how tokens get trusted that should never have been trusted.
The three parts
A JWT is three sections joined by dots: header.payload.signature. Each of the first two sections is a JSON object that has been base64url-encoded. The third is the cryptographic signature over the first two. Split on the dots and you have the whole structure:
- Header — a small JSON object describing the token itself. It names the signing algorithm (
alg, e.g.HS256orRS256) and the token type (typ, usuallyJWT). It may carry a key identifier (kid) so the verifier knows which key to use. - Payload — the JSON object carrying the actual data, called claims: who the user is, when the token expires, who issued it. This is the part applications care about.
- Signature — bytes computed over
base64url(header) + "." + base64url(payload)using a secret or private key. It is the only part that proves the token was not forged or altered.
The header and payload are encoded, not encrypted. That distinction is the whole game.
base64url is encoding, not encryption
Each part is encoded with base64url — a URL-safe variant of Base64 that uses - and _ instead of + and /, and drops the trailing = padding so the token survives inside URLs and headers untouched. Encoding is a reversible transformation with no key involved. Anyone who receives the token can split it on the dots, base64url-decode the middle section, and read every claim in plain text.
This has a blunt practical consequence: whatever you put in a JWT payload is public to anyone who holds the token. The browser can read it. Any proxy that sees the request can read it. A copy pasted into a decoder can read it. The signature stops people from changing the payload without detection; it does nothing to stop them from reading it. If you would not print a value on a postcard, it does not belong in a JWT payload.
Standard claims
RFC 7519 defines a set of registered claim names — three-letter keys with agreed meanings so that different systems interpret them the same way. The common ones:
iss(issuer) — who created and signed the token. Your verifier should check this matches the authority you actually trust.sub(subject) — who the token is about, typically a stable user ID.aud(audience) — who the token is intended for. An API should reject tokens whose audience is not itself, even if the signature is valid.exp(expiration time) — a Unix timestamp after which the token must be rejected. This is what makes a leaked token stop working eventually.iat(issued at) — when the token was created.nbf(not before) — a timestamp before which the token is not yet valid, useful for tokens minted in advance.
The timestamps are seconds since the Unix epoch, not human dates — a decoder that converts exp to a readable time saves you the arithmetic. Everything beyond these registered names is a custom (private) claim: roles, tenant IDs, feature flags. All of it, registered or custom, is readable by anyone with the token.
How signature verification works
The signature is what lets a server accept a token it did not itself store. Rather than looking the token up in a database, the server recomputes the signature over the header and payload it received and checks that it matches the signature attached. If a single character of the payload was altered, the recomputed signature will not match and the token is rejected. This is why the mechanism is trustworthy despite the payload being readable — readable is fine, tamper-evident is the point.
There are two broad families of algorithm, and the difference matters:
| Aspect | HS256 (symmetric) | RS256 (asymmetric) |
|---|---|---|
| Keys | One shared secret | Private key + public key pair |
| Signs with | The shared secret | The private key |
| Verifies with | The same shared secret | The public key |
| Best when | One party both signs and verifies | Many parties verify, only one signs |
HS256 uses one secret for both signing and verifying (an HMAC over SHA-256). It is simple and fast, but everyone who can verify can also mint tokens — so the secret cannot be shared with parties you only want to read tokens. RS256 uses a key pair: the issuer signs with a private key it guards closely, and anyone can verify with the freely distributable public key. Because the public key cannot create signatures, you can hand it to any number of downstream services and none of them can forge a token. That property is why RS256 dominates federated setups, single sign-on, and public APIs.
The common mistakes
- Trusting an unverified token. The headline error. Decoding the payload and reading
"role": "admin"tells you what the token claims, not whether the claim is real. An attacker can craft a token asserting anything. Only a successful signature check — plus checks onexp,issandaud— makes a claim trustworthy. Never make an authorization decision on decoded-but-unverified data. - Putting secrets in the payload. Passwords, API keys, private personal data, internal system details — none belong in a JWT, because the payload is public to anyone holding the token. Store only what you are comfortable being read.
- The
alg: noneattack. Early JWT libraries honoured a header of"alg": "none", meaning "no signature." An attacker strips the signature, setsalgtonone, and a naive verifier accepts the forged token. A related attack downgradesRS256toHS256and signs with the public key (which the server mistakenly uses as an HMAC secret). The defence is the same: pin the accepted algorithm on the server side and never let the token's own header dictate how it is verified.
When to decode versus when to verify
Decoding is a legitimate, everyday operation — for reading, debugging, and inspection. You decode when you want to see what is inside a token: checking why a login failed, confirming which claims your identity provider issues, or reading a non-security field for display. Decoding requires no key and asserts no trust.
You verify whenever the answer will drive a decision: granting access, returning a user's data, allowing an action. Verification requires the correct key and must include the expiry and audience checks, not just the signature. As a rule of thumb: decode freely to look, but verify before you act.
Tool walkthrough
Toolhub's JWT decoder splits a token on its dots and base64url-decodes the header and payload so you can read the claims as formatted JSON, converting the exp, iat and nbf timestamps into readable times. It runs entirely in the browser, so a token you paste is never transmitted anywhere — which matters, because a live token is a credential. Note what the decoder deliberately does not do: it shows you the header's stated algorithm but it does not tell you the token is genuine, because verification needs the signing key, which lives on your server and should stay there.
If you want to see the raw mechanics underneath, the base64 encoder lets you take a single section of a JWT and base64url-decode it by hand — proof, in one step, that the payload was never secret to begin with. Encoding a small JSON object the same way shows exactly how the header and payload sections are built.
Where to read further
- RFC 7519 — the canonical JSON Web Token specification, including the full list of registered claim names and their meanings.
- RFC 7515 — JSON Web Signature, which defines how the signature is computed and the
algvalues such as HS256 and RS256. - MDN: atob() — the browser API for Base64 decoding, illustrating how trivially the encoded parts of a token are read back to plain text.
Treat a JWT as a signed postcard: the message is there for anyone to read, and the value is entirely in the seal that proves who wrote it. Read them freely to understand what is going on, but never let a decoded claim make a decision on your behalf. Decoding shows you what a token says; only verification tells you whether to believe it.
← All articles