Three parts, two dots
A JWT is header.payload.signature, each part Base64URL-encoded:
- Header —
{"alg":"HS256","typ":"JWT"}: which algorithm signed it. - Payload — claims:
sub(subject),exp/iat/nbf (expiry/issued-at/not-before),iss/aud(issuer/audience), plus custom claims. Base64URL is encoding, not encryption — anyone can read the payload. Never put secrets in a JWT. - Signature — HS256: HMAC of the first two parts with a shared secret. RS256/ES256: signed with the issuer's private key; verifiable by anyone with the public key.
What the signature does and doesn't do
The signature guarantees integrity and authenticity — the claims are exactly what the issuer wrote. It does not guarantee confidentiality, revocation, or freshness beyond exp. A stolen but unexpired token works until it expires; keep lifetimes short (minutes) and use refresh tokens with rotation for sessions.
Verification checklist
- Split into three parts; decode header and payload.
- Pin the expected algorithm — verify
algis one you chose. The classic attack (alg=none / HS-vs-RS confusion) only works on verifiers that trust the header. - Verify the signature with the right key (shared secret for HS*, issuer's public key for RS*/ES*).
- Check
expandnbf(in seconds),issandaudmatch your expectations. - Only then trust the claims.
Pitfalls seen in production
- Leaking tokens in URLs — they land in logs, Referers, and history. Authorization headers only.
- Storing long-lived JWTs in localStorage — any XSS reads them. Prefer httpOnly cookies with CSRF protection, or short in-memory tokens.
- Ignoring
expon the client — "the API will 401 eventually" produces confusing failures; check expiry up front. - Confusing JWT and JWS/JWE — a signed JWT (JWS) is tamper-evident, not secret; an encrypted token (JWE) is a different object entirely.
Decode any token, inspect claims, and check expiry locally (nothing is transmitted) in the JWT Debugger.