How JWTs work: header, payload, signature, and why decoding is not verifying
JSON Web Tokens are how most APIs pass identity around: a login service issues a token, the client sends it with every request, and each API decides whether to trust it without calling the login service again. The idea is simple, but a surprising number of production incidents come down to one confusion: treating a token you can read as a token you can trust. This guide takes a real token apart, shows how the signature is made, and lists the checks that turn decoding into verification.
The three parts of a token
Here is an HS256 token, wrapped for readability. In practice it is one line with two dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJzdWIiOiI0MiIsImlzcyI6Imh0dHBzOi8vYXV0aC5leGFtcGxlLmNvbSIsImF1ZCI6Im9yZGVycy1hcGkiLCJpYXQiOjE3NjAwMDAwMDAsImV4cCI6MTc2MDAwMDkwMH0
.92ZV-gO01oaGuD_YbcpRTaMtjebc4xWigBCWuYRFlKc
- Header:
{"alg":"HS256","typ":"JWT"}. It says how the token was signed, and sometimes includes akid(key ID) saying which key was used. - Payload: the claims, here
{"sub":"42","iss":"https://auth.example.com","aud":"orders-api","iat":1760000000,"exp":1760000900}. - Signature: 32 bytes of HMAC-SHA256 over the first two parts.
Each part is Base64URL-encoded JSON (or bytes, for the signature). Base64URL is an encoding, not encryption, as the Base64 guide explains. Anyone holding the token can read the header and payload. Paste this one into the JWT Decoder and it shows them instantly. Never put passwords, API keys or sensitive personal data in a payload. If the contents must be secret, you need an encrypted JWE, which is a different format.
Decoding is ten lines of code
To make the point concrete, this is a complete JWT decoder for Node.js:
function decodeJwtUnsafe(token) {
const [header, payload] = token.split('.');
const json = (part) => JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
return { header: json(header), payload: json(payload) };
}
In a browser, replace - with + and _ with /, then use atob and TextDecoder. Notice what this function never touches: the signature and any key. That is fine for displaying the user's name in a UI, or for debugging. It is a security hole anywhere a decision is made, because an attacker can produce a token that decodes to anything:
const [h, , sig] = realToken.split('.');
const fake = { sub: '1', role: 'admin', exp: 9999999999 };
const forged = `${h}.${Buffer.from(JSON.stringify(fake)).toString('base64url')}.${sig}`;
decodeJwtUnsafe(forged).payload.role // 'admin'
Code that reads role from a decoded token without verifying it has just granted admin rights to anyone who can type. This is not hypothetical. It turns up regularly in reviews, usually as a middleware that "only needed the user ID" and called jwt.decode() instead of jwt.verify().
How the signature is made
For HS256, the signature is an HMAC of the exact string base64url(header) + "." + base64url(payload), keyed with a shared secret. Signing by hand shows there is no magic:
import crypto from 'node:crypto';
const b64url = (s) => Buffer.from(s).toString('base64url');
const header = b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
const body = b64url(JSON.stringify(claims));
const sig = crypto.createHmac('sha256', secret).update(`${header}.${body}`).digest('base64url');
const token = `${header}.${body}.${sig}`;
Change one character of the header or payload and the HMAC no longer matches. Without the secret, an attacker cannot compute a matching one. That is the whole security model, and it only works if someone actually recomputes and compares the signature.
HMAC versus public-key algorithms
HS256/384/512 use one shared secret to sign and verify. It is simple and fast, and fine when one service issues and checks its own tokens. But every verifier can also forge tokens, and the secret must be long and random, at least 32 random bytes for HS256. A guessable secret can be brute-forced offline from a single captured token with tools like hashcat.
RS256, PS256, ES256 and EdDSA sign with a private key and verify with a public key. This suits the common setup where an identity provider issues tokens and many APIs verify them: the APIs fetch public keys from a JWKS endpoint (usually /.well-known/jwks.json) and cannot mint tokens themselves. The kid header picks the key, which makes rotation painless.
What verification actually means
Use a maintained library and give it everything it needs. With jose in Node.js:
import { jwtVerify, createRemoteJWKSet } from 'jose';
const JWKS = createRemoteJWKSet(new URL('https://auth.example.com/.well-known/jwks.json'));
export async function authenticate(token) {
const { payload } = await jwtVerify(token, JWKS, {
algorithms: ['RS256'], // never trust the header to choose
issuer: 'https://auth.example.com',
audience: 'orders-api',
clockTolerance: 30, // seconds of allowed clock skew
});
return payload; // only now is payload.sub trustworthy
}
Each option maps to a check that has been the root cause of a real vulnerability:
- Algorithm allow-list. Old libraries accepted
"alg":"none", meaning no signature, or verified an RS256 token as HS256 using the public key as the HMAC secret. Pin the algorithm on the server. With the list set, jose rejects anonetoken withERR_JOSE_ALG_NOT_ALLOWED. - Signature with the right key for that issuer. The forged token above fails with
ERR_JWS_SIGNATURE_VERIFICATION_FAILED. - Expiry and not-before, with a small tolerance for clock skew. An expired token gives
ERR_JWT_EXPIRED. - Issuer. A token from your staging identity provider must not work in production.
- Audience. Without it, a token issued for one application works against every API that trusts the same provider. A token for
orders-apipresented tobilling-apifails withunexpected "aud" claim value. - Authorisation. A valid token says who the caller is, not what they may do. Check scopes or roles against the specific action, every time.
For HS256 the key is the secret as bytes, new TextEncoder().encode(secret), with algorithms: ['HS256']. The same principles apply in every language: PyJWT's jwt.decode(token, key, algorithms=["RS256"], audience="orders-api", issuer=...), or Spring Security's resource server configured with an issuer URI.
Claims worth knowing
| Claim | Meaning |
|---|---|
iss | Issuer, usually the identity provider's URL |
sub | Subject, the user or client ID |
aud | Audience, the API the token is for (a string or an array) |
exp, nbf, iat | Expiry, not-before and issued-at, in Unix seconds |
jti | Unique token ID, for deny lists and replay detection |
A recurring bug is writing exp in milliseconds from Date.now(), which produces a token that does not expire for tens of thousands of years. The Unix Timestamp Converter makes that obvious at a glance.
Expiry, refresh and logout
A JWT stays valid until exp, even after logout or account suspension, because nobody checks a database. Keep access tokens short-lived, five to fifteen minutes, and pair them with a refresh token that is checked against a database when exchanged. Revocation then takes effect within one access-token lifetime. If you need instant revocation, keep a deny list of jti values until they expire. At that point, ask whether a plain server-side session would be simpler. For a single web application it often is.
In the browser, a token in localStorage is readable by any script on the page, so one XSS bug leaks it. An HttpOnly; Secure; SameSite=Lax cookie keeps it away from JavaScript, at the cost of needing CSRF protection on state-changing requests.
Debugging a 401
When an API rejects a token that "should work", decode it first. That is exactly what decoding is good for. Then compare it with the checks above. Nearly every case is one of these: exp has passed; aud names a different API; iss is another environment's identity provider; the kid refers to a key the API has not fetched yet; or a server clock is minutes off. The JWT Decoder shows expiry as a readable date and can verify an HS256 signature against a secret you supply, all in your browser.