JSON Web Tokens are the default authentication mechanism for modern web applications. You've almost certainly used them — every time you log into a SPA, call an API with a Bearer token, or use OAuth, there's probably a JWT involved. But most developers treat them as opaque strings. Let's crack one open.
Anatomy of a JWT
A JWT is three Base64URL-encoded strings separated by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTcxMzI2MDgwMCwiZXhwIjoxNzEzMzQ3MjAwfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5cThat's three parts: header, payload, and signature, separated by periods.
Paste any JWT to instantly see the decoded header, payload, and expiry status. No data is sent to a server.
Part 1: The header
The first segment decodes to:
{
"alg": "HS256",
"typ": "JWT"
}The header tells you two things: what algorithm was used to sign the token (HS256 = HMAC-SHA256), and the type of token (JWT). That's it.
Common algorithms you'll encounter:
- HS256 (HMAC + SHA-256) — symmetric. The same secret key signs and verifies. Simple, fast, but the secret must be shared between issuer and verifier.
- RS256 (RSA + SHA-256) — asymmetric. A private key signs, a public key verifies. Standard for systems where the verifier shouldn't have the signing key (most production setups).
- ES256 (ECDSA + SHA-256) — asymmetric, like RS256, but with shorter keys and faster verification. Increasingly popular.
The algorithm field is important because it determines how the signature is verified. It's also the source of a notorious vulnerability — more on that below.
Part 2: The payload
The middle segment is where the actual data lives:
{
"sub": "1234567890",
"name": "John Doe",
"admin": true,
"iat": 1713260800,
"exp": 1713347200
}These key-value pairs are called claims. Some are standardised (registered claims), and you can add any custom data you want.
Registered claims (defined in RFC 7519):
| Claim | Full name | Purpose |
| --- | --- | --- |
| sub | Subject | Who the token is about (usually a user ID) |
| iss | Issuer | Who created the token (your auth server URL) |
| aud | Audience | Who the token is intended for (your API URL) |
| exp | Expiration | When the token expires (Unix timestamp) |
| iat | Issued at | When the token was created (Unix timestamp) |
| nbf | Not before | Token isn't valid before this time |
| jti | JWT ID | Unique identifier for this specific token |
The exp and iat claims are Unix timestamps. If you're staring at 1713260800 and wondering what date that is, the Unix timestamp converter will tell you it's 16 April 2024, 12:00:00 UTC.
Custom claims like name and admin in the example above can contain anything — user roles, permissions, organisation IDs, feature flags. This is the main selling point of JWTs: the token itself carries the data, so the API doesn't need to hit a database on every request to know who the user is.
Part 3: The signature
The third segment is the cryptographic signature. For HS256, it's computed like this:
HMAC-SHA256(
base64url(header) + "." + base64url(payload),
secret_key
)The signature proves that the token hasn't been tampered with. If someone changes a single character in the header or payload, the signature won't match, and any properly implemented verifier will reject the token.
What the signature does not do: encrypt anything. The header and payload are merely Base64-encoded, not encrypted. Anyone with access to the token can decode and read the payload. The signature only guarantees integrity (it hasn't been modified), not confidentiality (nobody else can read it).
This is the single most misunderstood aspect of JWTs.
JWTs are not encrypted
This bears repeating because it causes real security incidents: the payload of a JWT is readable by anyone who has the token. Base64 is an encoding, not encryption. There's no key required to decode it.
# Decode a JWT payload in your terminal:
echo "eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0" | base64 -d
# Output: {"sub":"1234567890","name":"John Doe"}This means you should never put sensitive data in a JWT payload: passwords, credit card numbers, SSNs, API keys, medical information. If you need encrypted tokens, look at JWE (JSON Web Encryption) — but in practice, most systems keep JWTs lean and store sensitive data server-side.
The Base64 encoder/decoder can decode the individual segments of a JWT if you want to inspect them manually.
Common JWT mistakes
Storing sensitive data in the payload. As above. The payload is public. Treat it like a URL parameter — visible to anyone in the request chain, including browser extensions, proxy servers, and log aggregators.
Not checking expiry. The exp claim is just a number in the payload. If your API doesn't explicitly check it, expired tokens work forever. Every JWT verification library has an option to validate expiry — make sure it's enabled.
Trusting the algorithm header. The alg field in the header tells the verifier which algorithm to use. A classic attack is to change alg to none, which some naive implementations accept — skipping signature verification entirely. Another attack changes alg from RS256 to HS256, then signs the token with the public key (which is, by definition, public). Modern JWT libraries protect against both, but only if you explicitly specify the expected algorithm rather than trusting the header.
Using JWTs as session tokens without revocation. A JWT is valid until it expires. If a user logs out, changes their password, or has their account compromised, you can't invalidate an existing JWT. You need either short expiry times (15 minutes) with refresh tokens, or a server-side deny list — both of which partially negate the "stateless" advantage of JWTs.
Setting expiry too long. Access tokens should live for 15-60 minutes. Refresh tokens can live for days or weeks but should be rotatable and revocable. A JWT that expires in 30 days is effectively a long-lived password.
JWTs vs session cookies
The choice between JWTs and traditional server-side sessions isn't as clear-cut as the framework docs suggest:
| Aspect | JWT | Server session | | --- | --- | --- | | State | Stateless (data in token) | Stateful (data on server) | | Scalability | No shared state needed | Requires session store (Redis) | | Revocation | Hard (need deny list) | Easy (delete the session) | | Size | Can get large (payload data) | Small (just a session ID) | | Security | Payload is readable | Server-side, not exposed | | Mobile / API | Natural fit (Bearer token) | Awkward (cookies in native apps) |
For a traditional server-rendered web app with a single backend, server sessions are simpler and more secure. JWTs shine in distributed systems where multiple services need to verify identity without a shared session store, and in mobile/API scenarios where cookies aren't practical.
Inspecting JWTs in practice
When debugging authentication issues, you'll frequently need to decode a JWT to check its claims. Avoid pasting tokens into random websites — they could be logged server-side.
The hashbox JWT decoder runs entirely in your browser. Nothing is sent to a server. It also highlights the three sections in different colours so you can see exactly where the header ends and the payload begins — which is useful when you're manually inspecting tokens in log output.
For command-line inspection:
# Decode just the payload (second segment)
echo "YOUR_JWT_HERE" | cut -d'.' -f2 | base64 -d 2>/dev/null | jq .This pipes the second dot-separated segment through Base64 decode and then formats the JSON with jq. Works on any machine with standard Unix tools.