You've seen JWT tokens everywhere — in Authorization: Bearer eyJ... headers, in cookies, in OAuth flows. But do you know what's actually inside one?
This post explains JWT structure from scratch, how to decode them manually, what each claim means, and the mistakes developers make when trusting them.
What Is a JWT?
A JWT (JSON Web Token) is a compact, URL-safe string that represents a set of claims. Claims are facts about a subject — usually a user — such as their ID, email, role, or when their session expires.
A JWT looks like this:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsIm5hbWUiOiJBbGljZSIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcwNDA2NzIwMCwiZXhwIjoxNzA0MDcwODAwfQ.SIGNATURE_HERE
It has exactly three parts separated by two dots:
- Header — algorithm and token type
- Payload — the claims (the actual data)
- Signature — verifies the token hasn't been tampered with
Why Three Parts?
Part 1: The Header
The header is a Base64URL-encoded JSON object. Decode it and you get:
{
"alg": "HS256",
"typ": "JWT"
}
-
algtells the receiver which algorithm was used to sign the token (HS256= HMAC-SHA256) -
typis alwaysJWT
Part 2: The Payload
This is where the actual data lives. Also Base64URL-encoded JSON:
{
"sub": "user_123",
"name": "Alice",
"role": "admin",
"iat": 1704067200,
"exp": 1704070800
}
The sub, iat, and exp fields are registered claims defined in RFC 7519. The name and role fields are custom claims added by the application.
Part 3: The Signature
The signature is computed as:
HMAC-SHA256(
base64url(header) + "." + base64url(payload),
secret_key
)
This is what prevents tampering. If anyone changes the payload (say, promoting their role from user to admin), the signature won't match — and a proper server-side validation will reject the token.
Decoding vs. Verifying
Here's the critical distinction developers often get wrong:
Decoding = reading the payload (anyone with the token string can do this)
Verifying = checking the signature (requires the signing key)
The payload is not encrypted. It's just Base64URL-encoded — a reversible transformation, not a security measure. Anyone who holds the token string can read every claim in it.
The signature ensures the payload wasn't modified after it was issued. But decoding is still useful for debugging: to check what claims a token actually contains, what expiry time was set, which user ID is included.
Decoding a JWT Manually
Base64URL decoding is straightforward in JavaScript:
function decodeJWT(token) {
const [headerB64, payloadB64] = token.split('.');
function b64urlDecode(str) {
// Base64URL → Base64
let s = str.replace(/-/g, '+').replace(/_/g, '/');
// Add padding
while (s.length % 4) s += '=';
// Decode UTF-8 bytes
const bytes = Uint8Array.from(atob(s), c => c.charCodeAt(0));
return JSON.parse(new TextDecoder().decode(bytes));
}
return {
header: b64urlDecode(headerB64),
payload: b64urlDecode(payloadB64),
};
}
The key steps: replace - with + and _ with / (Base64URL → standard Base64), add = padding, decode with atob(), then JSON-parse.
Or just paste the token into SnappyTools JWT Decoder — it decodes header, payload, and expiry status in one click with no setup.
Standard Claims (RFC 7519)
| Claim | Full Name | Meaning |
|---|---|---|
iss |
Issuer | Who issued the token |
sub |
Subject | Who the token is about (usually user ID) |
aud |
Audience | Who the token is intended for |
exp |
Expiration Time | Unix timestamp — reject after this time |
nbf |
Not Before | Unix timestamp — reject before this time |
iat |
Issued At | When the token was issued |
jti |
JWT ID | Unique ID — used to prevent replay attacks |
The timestamps (exp, nbf, iat) are Unix timestamps in seconds — not milliseconds. A common bug is checking exp < Date.now() (which is in milliseconds) instead of exp < Date.now() / 1000.
Common Signing Algorithms
The alg header tells the server how to verify the signature:
Symmetric (shared secret):
-
HS256,HS384,HS512— HMAC with SHA hash. The same key signs and verifies. Good for single-service tokens. Keep the key secret from clients.
Asymmetric (public/private key):
-
RS256,RS384,RS512— RSA. Sign with the private key, verify with the public key. Good for distributed systems where multiple services verify tokens. -
ES256,ES384,ES512— ECDSA. Same idea, smaller signatures.
⚠️ Never accept alg: none. This means the token has no signature and anyone can forge it. A class of vulnerabilities exists where attackers modify the header to "alg": "none" and strip the signature — if the server doesn't validate the algorithm, it accepts the forged token. Always specify which algorithms you accept.
Mistakes Developers Make
1. Trusting the decoded payload without verifying
// ❌ Wrong: decoding without verifying
const payload = JSON.parse(atob(token.split('.')[1]));
if (payload.role === 'admin') { ... }
If you decode without verifying the signature, an attacker can forge any payload. Always verify server-side.
2. Using JWT for sessions and then logging out
JWTs are stateless — there's no server-side record to delete. If a user logs out, the token is still valid until exp. You need a token denylist (or short expiry + refresh tokens) to handle early revocation.
3. Putting sensitive data in the payload
The payload is readable by anyone. Don't put passwords, PII, or secrets in JWT claims.
4. No expiry
A token without an exp claim is valid forever. Always set an expiry — even a generous one — so stolen tokens eventually become useless.
5. Storing access tokens in localStorage
localStorage is accessible to JavaScript — including injected scripts (XSS). Prefer HTTP-only cookies for access tokens where possible.
Debugging JWTs in Practice
When a JWT-authenticated request fails, the first thing to do is decode the token and check:
-
Is it expired? Check
expagainst current time. -
Is it for the right audience? Check
aud. -
Is it from the right issuer? Check
iss. -
Does it have the right algorithm? Check
algin the header. -
Does the payload have the expected claims? Check
sub,role,scope, etc.
You can do this manually (decode the Base64URL parts), use your IDE's debugger, or use a browser tool like SnappyTools JWT Decoder — paste the token, see everything decoded instantly, get the expiry countdown, and read all standard claims with human-readable labels.
Summary
- A JWT is
header.payload.signature— all Base64URL-encoded - The payload is not encrypted — anyone can read it
- The signature prevents tampering — but only if you verify it server-side
- Standard claims (
exp,iat,sub,iss) have defined meanings — use them - Never accept
alg: none, always set an expiry, and don't store secrets in the payload
Next time you see an eyJ... token, you'll know exactly what's inside.
Top comments (0)