Cheatsheet

JWT Structure Cheatsheet

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.

Quick reference

The three segments

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.

Header parameters

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.

Registered claims (RFC 7519 section 4.1)

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.

Signing algorithms (RFC 7518 section 3.1)

`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.

Common patterns

Build and sign an HS256 token

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.

Decode the payload without verifying it

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 the signature, then the claims

// 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.

Prove a tampered payload fails

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.

Check expiry with clock-skew leeway

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.

Decode a segment from the shell

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.

Pitfalls

  • Accepting 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.
  • Letting the token pick the algorithm: if your code reads 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.
  • Short HMAC secrets: RFC 7518 requires an HS256 key of at least 256 bits, which is 32 bytes. A short or guessable secret can be brute-forced offline from any one token.
  • Treating the payload as private: base64url is not encryption. Do not put passwords, API keys or personal data in a signed token. Use a JWE if the contents must be hidden.
  • Milliseconds in 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.
  • Skipping 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.
  • Wrong ES256 signature format: JWS wants the raw 64-byte R then S concatenation, not the ASN.1 DER form most OpenSSL tools print. In Node, pass dsaEncoding: 'ieee-p1363'.
  • Trusting 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.
  • No revocation: a valid token stays valid until exp. Keep lifetimes short and track jti values if you need to cut a token off early.

Related ZipKit tools

  • JWT Decoder — decodes the header and payload, shows expiry status and can verify signatures
  • Base64 Encode / Decode — handles URL-safe Base64 for reading a single segment by hand
  • Unix Timestamp Converter — turns exp, nbf and iat seconds into dates
  • Hash Generator — computes plain SHA-256 and similar hashes, but does not do HMAC, so it cannot produce a JWT signature

Related cheatsheets