What a JWT is, precisely
A JSON Web Token is three Base64Url strings joined by dots: a header describing the algorithm, a payload of claims, and a signature over the first two. Nothing in the first two segments is secret. Base64 is an encoding; atob on the payload returns readable JSON. The signature proves integrity and, with the right key, origin. It hides nothing.
That single fact explains most JWT incidents. Teams put a phone number, an internal role or an email in the payload, assuming the token is opaque because it looks unreadable.
Decoding one in ten seconds
const [header, payload] = token.split('.');
const decode = (part) => JSON.parse(
Buffer.from(part.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'),
);
console.log(decode(header)); // { alg: 'HS256', typ: 'JWT' }
console.log(decode(payload)); // { sub: 'user_1024', role: 'admin', exp: ... }
If that output surprises you, the token was designed wrong.
Signed is not encrypted
JWS and JWE are different structures with different guarantees. A JWS protects integrity and anyone can read it. A JWE protects confidentiality and the payload is ciphertext. If you must carry sensitive claims, use JWE or, far more commonly, put an opaque reference in the token and look the data up server-side.
Three fatal mistakes
- Sensitive data in the payload. Tokens end up in logs, browser history, proxy traces and crash reports. Treat every claim as public.
- Trusting a client-side decode. A frontend reading
role: adminproves nothing. The server must verify the signature and every relevant claim. - Ignoring the algorithm. Accepting whatever
algthe token declares is the root of the attacks below.
Algorithm confusion, concretely
alg: none. An old library accepts an unsigned token when the header says none. The fix is a hard-coded allow-list of algorithms, never a value taken from the token.
RS256 to HS256. The verifier expects an RSA public key. The attacker switches the header to HS256 and signs with that public key, which is public. A verifier that feeds the same key material into an HMAC check accepts it. Bind the algorithm to the key type per key, not globally.
Key confusion through kid. If the key ID is used to fetch a key from a user-controllable path, an attacker can point it at a key they own. Validate kid against a known set.
Validate more than the signature
| Claim | Why |
|---|---|
exp | Reject expired tokens; allow a few seconds of clock skew |
nbf | Reject tokens used before their not-before time |
iss | Only your issuer, not any tenant in a shared setup |
aud | Only your service; a token minted for another API must not work here |
jti | Replay protection when you keep a short-lived deny list |
A valid signature with the wrong audience is still an authorization bug.
Revocation is the real cost
Self-contained tokens cannot be un-issued without state. The practical compromise is a short access token of 5–15 minutes plus a refresh token stored server-side and revocable. When you need immediate logout, keep a deny list keyed by jti with a TTL equal to the token lifetime, so the list stays small.
Avoid "just make tokens last 30 days" to reduce refresh traffic. That converts a login problem into a revocation problem you cannot solve.
Where to store the token
localStorage is readable by any script on the page, so one XSS steals the session. An HttpOnly, Secure, SameSite=Lax cookie is not readable by JavaScript, which removes that theft path but reintroduces CSRF. The usual answer is a cookie for the refresh token plus a short-lived access token held in memory, with CSRF protection on state-changing requests.
Key rotation and JWKS
Publish public keys at a JWKS endpoint, include a kid in every token, and keep the previous key during rotation so in-flight tokens still verify. Rotate on a schedule and immediately after any suspected compromise. Without kid, rotation becomes a flag day.
JWT or session?
| JWT | Server session | |
|---|---|---|
| State | Stateless | Stored server-side |
| Revocation | Hard; needs short TTL or a deny list | Immediate |
| Scaling | No shared store needed | Needs shared store or sticky sessions |
| Request size | Larger, sent on every request | One opaque ID |
Most systems end up hybrid: short JWT access tokens plus a server-stored, revocable refresh token.
Size matters more than you think
Tokens ride in every request header. A full permission list can push one past 4 KB, which some proxies reject and every client pays for on every call. Keep claims minimal and fetch the rest server-side.
JWT inside OAuth2 and OIDC
In OIDC, the ID token is a JWT whose job is to tell the client who the user is. It is not an API credential and should not be sent to your resource servers. The access token is the credential, and it may or may not be a JWT. Mixing the two is a common source of authorization bugs.
Claims design: small and stable
A claim is a promise to every service that reads the token. Put identity and coarse authorization in it, such as sub, tenant and a role, and leave anything that changes often out. Every claim you add becomes part of a contract you cannot revoke before expiry.
Use registered claims where they exist: sub for the subject, iat for issue time, exp for expiry, jti for a unique token ID. Namespace custom claims so they do not collide with future standards.
Clock skew and leeway
Distributed systems drift. Verifiers should allow a small leeway, typically 30–60 seconds, on exp and nbf. Without it, a token issued by a server a minute ahead of the verifier is rejected immediately, which shows up as random authentication failures after a deploy.
A verification checklist in code
const { payload } = await jwtVerify(token, key, {
algorithms: ['RS256'], // pin the algorithm, never trust the header
issuer: 'https://auth.example.com',
audience: 'api.example.com',
clockTolerance: 60, // seconds
maxTokenAge: '15m',
});
// authenticate, then authorize separately
if (payload.role !== 'admin') throw new Error('forbidden');
Token lifetime and refresh strategy
Access token lifetime trades revocation latency against refresh traffic. Five to fifteen minutes keeps the window small without hammering the token endpoint. Pair it with a refresh token that is rotated on every use and bound to the client, so a stolen refresh token is detectable when the legitimate client presents the previous one.
Do not store the refresh token in the same place as the access token. If both live in localStorage, one XSS takes both and the short access token buys nothing.
Library pitfalls
- Decoding without verifying. Functions named
decodedo not check the signature; useverify. - Passing the algorithm from the token. Always supply an explicit allow-list.
- Ignoring errors. A verification failure must reject the request, not fall through with an empty payload.
The takeaway
A JWT is a signed, readable envelope, not a vault. Never put secrets in it, pin the algorithm, validate every claim, keep lifetimes short, and plan for revocation before you ship.
Try it: Base64 codec, Hash calculator.