This cheatsheet is a lookup for the pieces of a signed JWT: the three segments, the header parameters, the registered claims and the algorithm names. It is for developers who have a token in a log or an Authorization header and need to read it or validate it correctly. The confusion it clears up is that a signed JWT is encoded, not encrypted, so anyone holding it can read the payload. The JWT glossary page covers what a JWT is; this page is for reading and checking one.
A signed JWT (technically a JWS in compact form, RFC 7515) has exactly three segments joined by two dots. An encrypted JWT (a JWE, RFC 7516) has five segments joined by four dots.
| Segment | Contains | Encoding |
|---|---|---|
| 1. Header | JSON object with `alg` and usually `typ` | base64url |
| 2. Payload | JSON object of claims | base64url |
| 3. Signature | Bytes signed over `header.payload` | base64url |
The signature covers the ASCII text of the first two segments exactly as transmitted, including the dot. Base64url uses - and _ instead of + and /, and drops the = padding.
| Name | Meaning |
|---|---|
| `alg` | Signing algorithm, such as `HS256` or `ES256` |
| `typ` | Media type of the token, recommended value `JWT` (RFC 7519 5.1) |
| `kid` | Key ID, used to pick the verification key |
| `cty` | Content type, set to `JWT` only for nested tokens |
| `jku` | URL of a JWK Set that holds the key |
| `jwk` | The public key itself, embedded in the header |
| `x5u`, `x5c`, `x5t` | X.509 certificate URL, chain and thumbprint |
| `crit` | Lists extension parameters the receiver must understand |
Access tokens that follow RFC 9068 use typ set to at+jwt.
All are optional. Each application decides which ones it requires.
| Claim | Name | Type | Check on receipt |
|---|---|---|---|
| `iss` | Issuer | string or URI | Equals the issuer you trust |
| `sub` | Subject | string or URI | Unique within the issuer |
| `aud` | Audience | string or array | Must contain your own identifier, or reject |
| `exp` | Expiration time | NumericDate | Current time must be before it |
| `nbf` | Not before | NumericDate | Current time must be at or after it |
| `iat` | Issued at | NumericDate | Gives the age of the token |
| `jti` | JWT ID | string | Unique ID, usable to detect replay |
A NumericDate is a JSON number of seconds since 1970-01-01T00:00:00Z, ignoring leap seconds. It is seconds, not milliseconds. Fractional values are allowed.
| `alg` | Algorithm | Key type | Signature size |
|---|---|---|---|
| `HS256` | HMAC with SHA-256 | Shared secret, 32 bytes or more | 32 bytes, 43 characters |
| `HS384`, `HS512` | HMAC with SHA-384, SHA-512 | Shared secret | 48 and 64 bytes |
| `RS256` | RSASSA-PKCS1-v1_5 with SHA-256 | RSA key pair | 256 bytes for a 2048-bit key |
| `PS256` | RSASSA-PSS with SHA-256 | RSA key pair | Same as the RSA modulus size |
| `ES256` | ECDSA P-256 with SHA-256 | EC key pair | 64 bytes, 86 characters |
| `ES384`, `ES512` | ECDSA P-384, P-521 | EC key pair | 96 and 132 bytes |
| `EdDSA` | Edwards-curve signature (RFC 8037) | OKP key pair | 64 bytes for Ed25519 |
| `none` | No signature | None | Empty third segment |
RFC 7518 marks only HS256 as required to implement. RS256 is recommended, and ES256 is recommended with a note that the requirement is likely to rise.
const crypto = require('crypto');
const b = o => Buffer.from(JSON.stringify(o)).toString('base64url');
const secret = '0123456789abcdef0123456789abcdef';
const payload = { iss: 'https://auth.example.com', sub: 'user_123',
aud: 'api.example.com', iat: 1767225600, exp: 1767229200 };
const input = b({ alg: 'HS256', typ: 'JWT' }) + '.' + b(payload);
const sig = crypto.createHmac('sha256', secret).update(input).digest('base64url');
console.log(input + '.' + sig);
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJ1c2VyXzEyMyIsImF1ZCI6ImFwaS5leGFtcGxlLmNvbSIsImlhdCI6MTc2NzIyNTYwMCwiZXhwIjoxNzY3MjI5MjAwfQ.YYDBuY0coWzgUQs6wGo6ZeVLjiUeTlYkN5w3HFfiYN8
The iat of 1767225600 is 2026-01-01T00:00:00Z and exp is exactly one hour later. Use this pattern for tests, not as a replacement for a maintained JWT library.
const tok = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyJ9.2uIZf_tOfZSFZeQCRxBTe-nGH0YqwrQNz7lXczIn-kU';
console.log(Buffer.from(tok.split('.')[1], 'base64url').toString());
{"sub":"user_123"}
This reads the claims and proves nothing about who wrote them. Never make an access decision from a payload you only decoded.
// verify.js, run as: node verify.js <token>
const crypto = require('crypto');
const secret = '0123456789abcdef0123456789abcdef';
const tok = process.argv[2];
const [h, p, s] = tok.split('.');
const header = JSON.parse(Buffer.from(h, 'base64url'));
if (header.alg !== 'HS256') throw new Error('alg not allowed');
const good = crypto.createHmac('sha256', secret).update(h + '.' + p).digest();
const ok = crypto.timingSafeEqual(good, Buffer.from(s, 'base64url'));
console.log('signature valid:', ok);
signature valid: true
Pin the accepted algorithm in your code, as RFC 8725 section 3.1 requires, and compare in constant time. Check exp, nbf, iss and aud only after the signature passes.
const crypto = require('crypto');
const secret = '0123456789abcdef0123456789abcdef';
const token = require('fs').readFileSync('tok.txt', 'utf8').trim(); // the token printed above
const [h, p, s] = token.split('.');
const sign = input => crypto.createHmac('sha256', secret).update(input).digest('base64url');
const claims = JSON.parse(Buffer.from(p, 'base64url'));
const forged = Buffer.from(JSON.stringify({ ...claims, sub: 'admin' })).toString('base64url');
console.log('original valid:', sign(h + '.' + p) === s);
console.log('forged valid: ', sign(h + '.' + forged) === s);
original valid: true
forged valid: false
The forged payload swaps sub to admin and reuses the old signature. Any change to either of the first two segments changes the signed bytes, so the check fails.
const now = 1767229230, exp = 1767229200, leeway = 60;
console.log(now < exp + leeway ? 'accept' : 'reject', now < exp ? 'accept' : 'reject');
accept reject
The first value uses a 60-second leeway and the second uses none. RFC 7519 allows a small leeway, usually no more than a few minutes.
P=eyJzdWIiOiJ1c2VyXzEyMyIsImV4cCI6MTc2NzIyOTIwMH0
echo "$P" | base64 -d
{"sub":"user_123","exp":1767229200}base64: invalid input
GNU base64 prints the JSON but exits with status 1 because the padding is missing. Python needs + '=' * (-len(t) % 4) before urlsafe_b64decode.
alg: none: RFC 7519 allows an unsecured JWT with an empty third segment, for example eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyXzEyMyJ9.. RFC 8725 says libraries should not consume it unless the caller asks. Pass an explicit algorithm list to the verify call.alg from the header and uses it, an attacker can switch an RS256 token to HS256 and sign with the public key as the HMAC secret. Fix the algorithm per key, as RFC 8725 section 3.1 requires.exp: Date.now() returns milliseconds, but exp is seconds. A value like 1767229200000 is the year 57,971 and the token never expires. Divide by 1000.aud and iss: a valid signature from your own identity provider does not mean the token was meant for this API. RFC 7519 says to reject the token when aud is present and does not name you.dsaEncoding: 'ieee-p1363'.kid, jku and x5u blindly: RFC 8725 section 3.10 warns that kid can feed injection and that fetching a jku URL from an attacker-controlled token enables SSRF. Match URLs against an allow list.exp. Keep lifetimes short and track jti values if you need to cut a token off early.exp, nbf and iat seconds into dates