"JWT is base64 with a signature. That's it. The mystique around it disappears when you can decode a JWT by hand on a napkin — and the security model becomes clear: anyone can read the claims, only the signing key holder can issue valid ones."
The Three-Part Format
A JWT is three base64url-encoded parts joined by dots: HEADER.PAYLOAD.SIGNATURE. Each part is a JSON object (header and payload) or a binary signature, encoded so the whole thing fits in an HTTP header.
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1XzQyIiwiZXhwIjoxNzYwMDAwMDAwfQ.signature_bytes
Header: {"alg":"HS256","typ":"JWT"} — algorithm + type. Payload: {"sub":"u_42","exp":1760000000} — the claims (subject + expiry, here). Signature: HMAC-SHA256 or RSA signature of base64(header) + "." + base64(payload) using the issuer's secret/private key.
Anyone can copy a JWT, base64-decode its first two parts, and read the claims. The signature proves the token was issued by someone with the signing key and hasn't been altered. JWT is signed, not encrypted. Putting secrets in the payload is a top-five mistake.
Standard Claims (and Why You Use Them)
RFC 7519 reserves a handful of three-letter claim names:
- iss (issuer) — who minted the token. Lets clients trust the right signing key.
- sub (subject) — who/what the token represents (usually a user ID).
- aud (audience) — who the token is for. Lets one issuer serve many services without cross-contamination.
- exp (expiry) — Unix timestamp after which the token is invalid. Always include.
- nbf (not before) — Unix timestamp before which the token is invalid. Rare; useful for scheduled access.
- iat (issued at) — Unix timestamp when minted. Useful for audit + grace windows.
- jti (JWT ID) — unique ID. Required if you want to support revocation (you blacklist by jti).
Add your own claims freely (role, scope, org_id). Keep them small — every JWT is sent on every request.
The Three Critical Security Gotchas
1. alg=none. Early JWT libraries accepted {"alg":"none"} tokens — no signature required. An attacker could craft any token and your library would accept it. Always reject alg: none at the verifier; modern libraries do this by default, but verify yours does.
2. Algorithm confusion. An attacker takes a token signed with RS256 (asymmetric) and tells the verifier it's HS256 (symmetric). If your code looks up the verification key by algorithm, the verifier uses the PUBLIC key as the HMAC key — which the attacker already has — and accepts the forged token. Always pin the expected algorithm.
3. Putting secrets in the payload. Payload is base64-encoded, not encrypted. Anyone with the token can read it. Don't put passwords, internal user details, or anything you don't want a leaked token to expose.
HS256 vs RS256 — Which Algorithm
- HS256 (HMAC-SHA256, symmetric): one shared secret. Issuer and verifier are the same party (or fully trust each other). Simpler; fast; good for single-service backends.
- RS256 (RSA, asymmetric): private key signs; public key verifies. Issuer (auth server) holds the private key; verifiers (multiple resource servers) hold only the public key. Use when issuer ≠ verifier or when distributing verification widely.
Most identity providers (Auth0, Okta, Google, Apple) use RS256. Single-service apps often use HS256 for simplicity.
The Refresh Dance
Short access tokens (~15 minutes) keep leak windows tight. But forcing users to log in every 15 minutes is hostile. The compromise: pair the access token with a longer-lived refresh token (~30 days):
- Login issues both: access (15m) + refresh (30d).
- Client uses access on every API call. When it nears expiry, client posts the refresh token to
/auth/refreshand receives a new access (and optionally a new refresh, rotating it). - Refresh tokens are stored more carefully than access tokens — usually HttpOnly cookies, never in localStorage, never sent on regular API calls.
- Logout revokes the refresh token server-side; the access token expires naturally within 15 minutes.
The refresh rotation pattern: each successful /refresh also issues a NEW refresh token and invalidates the old one. This bounds the damage if a refresh token leaks — the legitimate user's next refresh detects the duplicate use and invalidates the entire session.