What's Actually Inside a JWT (and What You Shouldn't Put There)
JSON Web Tokens are everywhere: OAuth access tokens, session cookies, links in password-reset emails, service-to-service calls. They also look intimidating, a long string starting with eyJ. Once you see how they're put together, they become much less mysterious, and a few common security mistakes become obvious.
Three parts and two dots
A JWT is three Base64URL-encoded segments separated by dots:
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI0MiIsImV4cCI6MTc2MDAwMDAwMH0.Mq3x...
The eyJ prefix is simply {" in Base64, which is a handy way to recognise a JWT in logs. Decode the first two segments and you get plain JSON. Paste any token into the JWT decoder to see this for yourself.
The header
{ "alg": "RS256", "typ": "JWT", "kid": "2026-09-key" }
alg names the signing algorithm. HS256 uses a shared secret (HMAC). RS256 and ES256 use a private key to sign and a public key to verify, which is what you want when many services need to verify tokens but only one should issue them. kid tells the verifier which key to use, so keys can be rotated.
The payload
{
"iss": "https://auth.example.com",
"sub": "user_42",
"aud": "api.example.com",
"iat": 1760000000,
"exp": 1760000900,
"scope": "read:orders"
}
These are the claims. Some are registered in the spec: issuer, subject, audience, issued-at and expiry. The rest are whatever your application needs. Times are Unix timestamps in seconds, not milliseconds.
The signature
The signature is calculated over the first two segments using the algorithm and key from the header. If anyone changes a single character of the header or payload, say promoting "role": "user" to "role": "admin", the signature no longer matches and a correctly written server rejects the token.
Signed is not secret
The most important thing to understand is that a standard JWT is signed, not encrypted. Anyone who gets hold of the token can read the payload. The signature only proves it hasn't been tampered with.
So don't put anything in a JWT that the holder (or anyone who intercepts it, or any log it ends up in) shouldn't see: no passwords, no full personal details, no internal pricing rules. A user ID and a list of permissions is typical. If you genuinely need an encrypted token, there's a separate standard called JWE, but most systems are better off keeping sensitive data on the server and putting only a reference in the token.
Decoding is not verifying
Front-end code often decodes the access token to show the user's name or check whether it's about to expire. That's fine, as long as no security decision depends on it. Anyone can write a payload saying "admin": true. Only the server, using the secret or public key, can tell a real token from a forged one.
On the server, use a well-maintained library and configure it strictly:
- Pin the algorithm. Tell the library which algorithm you expect. Older libraries had a notorious bug where a token with
"alg": "none"was accepted without any signature, and another where an attacker could switch RS256 to HS256 and sign with the public key. - Check
exp,issandaud. A token issued for a different service shouldn't work on yours. - Allow a little clock skew, about 30–60 seconds, so servers with slightly different clocks don't reject fresh tokens.
The revocation problem
JWTs are attractive because a server can verify them without a database lookup. The flip side is that a token stays valid until it expires, even if the user logs out or their account is disabled. Common approaches:
- Keep access tokens short-lived (5 to 30 minutes) and pair them with a refresh token that is checked against a database.
- Maintain a small deny-list of revoked token IDs (
jti) until they expire. - Store a "token version" per user and bump it on password change.
Where to store them in a browser
There's no perfect answer, but there's a sensible default. localStorage is readable by any JavaScript on the page, so one XSS bug exposes every token. An HttpOnly, Secure, SameSite cookie can't be read by scripts at all, which removes that risk, though you then need to think about CSRF (largely handled by SameSite=Lax or Strict). For most web apps, cookies for the refresh token and a short-lived access token kept in memory is a solid setup.
A debugging checklist
- Decode the token. Is the
audwhat your API expects? - Check
expwith a timestamp converter. Is it in seconds? Has it expired? - Does the
kidin the header exist in the issuer's published keys? - Is the server's clock correct?
That list resolves most "401 Unauthorized, but the token looks fine" mysteries.
Inspect JWT headers, payloads and expiry times without sending the token anywhere.
Open the JWT Decoder