If you've worked with any modern authentication system — OAuth, Auth0, Firebase, Supabase, your own API — you've dealt with JWT tokens. You've probably copied one from a network request and stared at a long string of letters that looks like random noise:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsIm5hbWUiOiJKb2huIERvZSIsImlhdCI6MTc0NTQ4ODgwMCwiZXhwIjoxNzQ1NDkyNDAwfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
It's not random. Every part means something specific. Here's exactly what's in there.
The structure: three parts separated by dots
A JWT is always three Base64URL-encoded sections joined by dots:
[header].[payload].[signature]
If you split the token above on ., you get three separate strings. Decode each one (Base64URL → JSON) and the structure becomes clear.
Part 1: The header
The first section encodes a small JSON object that describes the token itself:
{
"alg": "HS256",
"typ": "JWT"
}
-
alg— the algorithm used to sign the token. Common values:HS256(HMAC-SHA256),RS256(RSA + SHA256),ES256(ECDSA + SHA256) -
typ— always"JWT"for JWT tokens
The header tells the receiving server how to verify the signature. Different algorithms have different security properties — HS256 uses a shared secret, RS256 uses public/private key pairs.
Part 2: The payload (the interesting part)
The payload is where the actual data lives. It's another Base64URL-encoded JSON object:
{
"sub": "user_123",
"name": "John Doe",
"iat": 1745488800,
"exp": 1745492400
}
These key-value pairs are called claims. Some are standardised (defined in RFC 7519):
| Claim | Full name | Meaning |
|---|---|---|
sub |
Subject | Who the token is about (usually a user ID) |
iss |
Issuer | Who created the token (your auth server's domain) |
aud |
Audience | Who the token is intended for |
exp |
Expiration time | Unix timestamp — token invalid after this |
nbf |
Not before | Unix timestamp — token invalid before this |
iat |
Issued at | Unix timestamp — when the token was created |
jti |
JWT ID | Unique identifier for this token (prevents replay) |
Beyond these, you can include any custom claims — roles, permissions, user metadata. Whatever your application needs.
Part 3: The signature
The signature is created by:
- Taking
base64url(header) + "." + base64url(payload) - Signing that string using the algorithm and key specified in the header
For HS256:
HMAC-SHA256(
secret_key,
base64url(header) + "." + base64url(payload)
)
The server that receives the token re-runs this calculation and compares the result to the signature in the token. If they match, the payload hasn't been tampered with.
Important: the signature verifies integrity, not confidentiality. The payload is Base64URL-encoded, not encrypted. Anyone can read the claims in a JWT — just paste it into a decoder. Never put sensitive data (passwords, full credit card numbers, SSNs) in a JWT payload.
Decoding vs verifying
There's a difference developers often blur:
Decoding means base64url-decoding the header and payload to read the JSON. You don't need the secret key for this. It tells you what's claimed — but you can't trust it without verification.
Verifying means checking the signature using the correct key. This confirms the token was issued by who it claims and hasn't been modified.
When debugging, you just want to decode — see what claims the server put in the token, check the expiry, inspect the user ID. You don't need the secret key for that.
Try it: paste any JWT into the SnappyTools Base64 Encoder/Decoder — just take the middle section (the payload), add padding if needed (= chars), and decode it. You'll see the raw JSON. Or use the JSON Formatter to prettify the output.
Common mistakes
Mistake 1: putting secrets in the payload
The payload is readable by anyone who has the token. If you put "creditCard": "4111..." in there, anyone who intercepts or steals the token can read it. Use JWEs (JSON Web Encryption) if you need confidentiality, or store sensitive data server-side and only put a reference ID in the JWT.
Mistake 2: ignoring expiry
The exp claim is just a number. The server receiving the token is responsible for checking it. If your code doesn't validate exp, expired tokens keep working forever. Always validate the expiry.
Mistake 3: algorithm confusion
The alg field in the header tells the server which algorithm to use for verification. Early JWT libraries trusted the header unconditionally — an attacker could set alg: none and submit an unsigned token that passed verification. Modern libraries fix this, but always configure your library to accept only specific algorithms, not whatever the token claims.
Mistake 4: storing JWTs in localStorage
Tokens in localStorage are accessible to JavaScript, including malicious scripts injected via XSS attacks. For long-lived tokens, prefer httpOnly cookies — inaccessible to JavaScript. Short-lived access tokens in memory (not persisted) are a reasonable tradeoff for some SPAs.
When the token expires
The exp claim is a Unix timestamp. If exp: 1745492400, the token is valid until that exact second. Many systems issue short-lived access tokens (15 minutes to 1 hour) paired with longer-lived refresh tokens — the access token expires frequently, the refresh token is used to get a new one without re-authentication.
To debug expiry issues: decode the token, find the exp claim, and convert the Unix timestamp to a readable date. The difference between iat and exp tells you the token's intended lifetime.
Understanding JWT structure makes debugging authentication much less frustrating. When something breaks — invalid token, expired token, wrong permissions — you can decode the token directly and see exactly what claims are in there, rather than guessing. The three-part structure is simple once you've seen it a few times, and the signing mechanism is one of the more elegant solutions to stateless authentication.
Top comments (0)