What a JSON Web Token actually contains
A JSON Web Token (JWT) is three chunks of text joined by full stops. Each chunk is Base64url encoded, and the first two are just JSON. Anyone who has the token can read what is inside it. Nothing in a normal JWT is encrypted.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 <- header
eyJzdWIiOiIxMjM0NSIsIm5hbWUiOiJBbGV4In0 <- payload
dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFW <- signature
Joined with full stops, that is the token you paste into a request. Decode the first two parts and you get ordinary JSON:
{ "alg": "HS256", "typ": "JWT" }
{ "sub": "12345", "name": "Alex" }
A real payload usually carries timestamps and an audience as well:
{
"iss": "https://auth.example.com",
"sub": "12345",
"aud": "api.example.com",
"iat": 1756000000,
"exp": 1756003600,
"roles": ["editor"]
}
The header
The header describes how the token is signed. Two fields matter most:
algnames the signing algorithm, for exampleHS256(HMAC with SHA-256, using a shared secret) orRS256(RSA signature with SHA-256, signed with a private key and checked with a public one).kid, the key ID, is optional and tells the recipient which of several keys was used, so keys can be rotated without breaking existing tokens.
typ is usually just JWT and carries no security meaning.
The payload and its claims
The payload is a JSON object of claims, which is simply the specification's word for statements about the subject of the token. Some claim names are registered by the standard, and they are all short:
| Claim | Name | What it holds |
|---|---|---|
iss | Issuer | Who created the token, usually a URL or service name |
sub | Subject | Who the token is about, usually a stable user ID |
aud | Audience | Who the token is intended for, so a service can reject tokens meant for a different one |
exp | Expiration time | The instant after which the token must be rejected |
nbf | Not before | The instant before which the token must be rejected |
iat | Issued at | When the token was created |
jti | JWT ID | A unique identifier, useful for tracking or blocking individual tokens |
exp, nbf and iat are all NumericDate values, meaning seconds since 1 January 1970 UTC. They are seconds, not milliseconds. If a timestamp decodes to a date in the year 57,000, you are reading milliseconds and need to divide by a thousand.
Everything else in the payload is whatever the issuer put there: roles, permissions, an email address, a tenant ID. Because a private claim name could one day collide with a registered one, issuers often namespace their own with a URL-like prefix.
The signature
The signature is computed over the exact text of the first two parts joined by a dot, using the algorithm from the header and a key. Change a single character of the header or payload and the signature no longer matches.
With HS256 the same secret both signs and verifies, so anyone who can check a token can also mint one. With RS256 or ES256 a private key signs and a public key verifies, which is what lets an identity provider hand out tokens that many separate services can validate without holding anything secret.
Base64url is not encryption
Base64url is the same idea as Base64, with two differences: it uses - and _ in place of + and / so the result is safe in URLs, and the trailing = padding is usually stripped. It is an encoding, a reversible way of writing bytes as text. It provides no confidentiality whatsoever.
So never put anything sensitive in a JWT payload: no passwords, no card numbers, no internal notes about the user. Assume the person holding the token, and anyone who reads it out of a log file or browser storage, can see every field.
There is a separate format, JWE, that does encrypt the payload, and it has five parts rather than three. If your token has three parts, it is signed and readable.
Decoding is not verifying
This is the point that matters. A decoder shows you what a token says. It does not tell you whether the token is genuine. Verification is a separate step and needs the key.
To actually trust a token, a server has to:
- Check the signature against the correct key.
- Insist on the algorithm it expects, rather than trusting the
algfield in the token. Two classic attacks come from skipping this: settingalgtononeand stripping the signature, and taking a service's RSA public key and using it as an HMAC secret so anRS256verifier is tricked into runningHS256. - Check
expand, if present,nbf, allowing a small clock skew. - Check
issandaudmatch what this service expects.
Only then do the claims mean anything. Until then the payload is an unverified string that arrived over the network.
Practical notes
- Tokens are usually sent in an HTTP header as
Authorization: Bearer <token>, so keep the payload small. Every claim you add is sent on every request. - A signed token stays valid until it expires. There is no built-in way to revoke one, which is why access tokens tend to have short lifetimes, often minutes, with a separate refresh token used to get new ones.
- If a token looks malformed, count the dots first. Two dots and three parts is a normal signed JWT. A truncated token pasted from a terminal is a very common cause of "invalid signature".
- Decoding a token to read its expiry, its subject or its roles while debugging is completely safe and does not require any secret. That is exactly what the payload is for.