How JSON Web Tokens actually work — and how to read one safely
A JWT is three base64 chunks and a signature. Here's what each part means, what it protects, and the mistakes that turn tokens into vulnerabilities.
If you've ever logged into a web app built in the last decade, you've almost
certainly carried a JSON Web Token around in your browser without noticing. JWTs
are the default currency of stateless authentication: a compact, self-contained
way to say "the bearer of this token is user 4823, and here's cryptographic proof
I issued it." They show up in Authorization: Bearer headers, OAuth flows,
single sign-on, and API keys everywhere.
They're also widely misunderstood, and that misunderstanding causes real security bugs. Let's take one apart.
The anatomy: three parts, two dots
A JWT is a single string with three sections separated by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0ODIzIiwibmFtZSI6IkFyZXVtIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Split on the dots and you get header.payload.signature. The first two parts are
just Base64URL-encoded JSON — not encryption, just encoding. The third is a
cryptographic signature over the first two.
Part 1: the header
Decode the first chunk and you get JSON like:
{ "alg": "HS256", "typ": "JWT" }
alg is the signing algorithm; typ is almost always JWT. The alg field
matters enormously for security, as we'll see.
Part 2: the payload (the claims)
The middle chunk is the interesting one. Decoded:
{
"sub": "4823",
"name": "Areum",
"role": "admin",
"iat": 1718000000,
"exp": 1718003600
}
Each key is a claim. Some are standardized (registered claims):
| Claim | Meaning |
|---|---|
sub | Subject — who the token is about (usually a user ID) |
iss | Issuer — who created the token |
aud | Audience — who the token is intended for |
exp | Expiration time (Unix seconds) |
iat | Issued-at time |
nbf | Not-before — token is invalid before this time |
jti | Unique token ID (useful for revocation lists) |
Everything else is a custom claim your application defines — role, email,
tenant_id, whatever you need.
Part 3: the signature
The signature is what makes a JWT trustworthy. The issuer takes
base64url(header) + "." + base64url(payload), runs it through the algorithm in
alg using a secret (or a private key), and appends the result. Anyone holding
the secret (or the matching public key) can recompute the signature and confirm
the token hasn't been altered.
The single most important thing to understand
A JWT is signed, not encrypted. The payload is Base64URL — reversible by anyone, no key required. Paste any token into a decoder and you'll read every claim in plain text.
This has two consequences that developers get wrong constantly:
- Never put secrets in a JWT. No passwords, no API keys, no credit card numbers, no personal data you wouldn't hand to the client. The user's browser can read all of it, and so can anyone who intercepts it.
- The signature protects integrity, not confidentiality. It guarantees
nobody tampered with the claims — it does nothing to hide them. If you flip
"role": "user"to"role": "admin"and re-encode, the signature no longer matches and a correct server rejects the token. But the contents were never secret.
HS256 vs RS256: symmetric vs asymmetric
The alg header names the signing scheme. The two you'll meet most:
- HS256 (HMAC-SHA256) is symmetric: the same secret both signs and verifies. Simple, fast, and fine when one party issues and verifies the token. The risk: every service that needs to verify must hold the signing secret, and any one of them could forge tokens.
- RS256 (RSA-SHA256) is asymmetric: a private key signs, and a freely distributable public key verifies. This is what you want for SSO and OAuth — your auth server keeps the private key, and a dozen microservices verify with the public key without ever being able to mint tokens themselves.
The classic JWT vulnerabilities
JWTs have a notorious history of implementation bugs. The famous ones are worth knowing because they're still found in the wild.
The alg: none attack. Early in JWT's life, the spec allowed "alg": "none"
— an unsigned token. Attackers discovered that some libraries, told none, would
happily skip signature verification entirely. Forge any payload, set alg to
none, drop the signature, and you're an admin. The fix: never let the token
tell you whether to verify it. Your server must enforce an expected algorithm.
The RS256 → HS256 confusion attack. If a server verifies with "whatever alg
says" and holds an RSA public key, an attacker can switch alg to HS256 and
sign the token using the public key as the HMAC secret. Since the public key is
public, they can now forge valid tokens. Again: the server must pin the expected
algorithm, not trust the header.
The lesson behind both: alg is attacker-controlled input. Configure your
verifier with the algorithm and key you expect, and reject anything else.
Reading a token in practice
To inspect a token — say you're debugging why an API keeps returning 401 — you
don't need the secret to read it. You only need the secret to verify it. Pop
the token into a JWT Decoder and you can immediately see the
header, the claims, and the exp timestamp. Nine times out of ten the bug is
right there: the token expired, the aud doesn't match, or nbf is in the
future because of clock skew between two servers.
A good decoder that runs entirely in your browser is the safe way to do this — you're often inspecting real session tokens, and those should never be pasted into a tool that phones home.
Expiration, refresh, and revocation
JWTs are stateless, which is their superpower and their weakness. The server doesn't store them, so it can't easily "log someone out" — a validly signed, unexpired token works until it expires no matter what. Real systems handle this with two patterns:
- Short-lived access tokens (minutes) plus long-lived refresh tokens. The access token does the work; when it expires the client trades a refresh token for a new one. This bounds the damage of a leaked access token.
- A revocation list keyed on
jtifor the rare case where you need to kill a specific token before it expires.
The takeaway
A JWT is three Base64URL segments: a header naming the algorithm, a payload of
readable claims, and a signature proving the first two weren't tampered with.
Remember that the payload is encoded, not encrypted — so keep secrets out of it
— and that the alg header is attacker-controlled, so your server must pin the
algorithm it expects. Get those two ideas right and you've avoided the majority of
the ways JWTs go wrong.