JSON Web Tokens (JWTs) are the backbone of modern web authentication and API authorization. They appear in OAuth 2.0 flows, OpenID Connect, and the authorization headers of virtually every REST and GraphQL API built in the last decade. Despite their prevalence, JWTs are frequently misunderstood — and that misunderstanding leads to serious security vulnerabilities in production systems.
The Three Parts of a JWT
A JWT consists of three Base64URL-encoded sections separated by dots. The structure is always: header.payload.signature
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFsaWNlIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Header: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
→ {"alg": "HS256", "typ": "JWT"}
Payload: eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFsaWNlIiwiaWF0IjoxNTE2MjM5MDIyfQ
→ {"sub": "1234567890", "name": "Alice", "iat": 1516239022}
Signature: SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
(binary HMAC-SHA256 hash, not human-readable JSON)Understanding JWT Claims
The JWT payload contains "claims" — statements about the subject of the token. The JWT specification (RFC 7519) defines several registered claim names that have standard meanings:
- sub (Subject): The principal this token is about — typically a user ID or account identifier
- iss (Issuer): The party that issued the token, usually a URL like "https://auth.example.com"
- aud (Audience): The intended recipients of this token — the server should verify it matches
- exp (Expiration Time): Unix timestamp after which the token must be rejected
- iat (Issued At): Unix timestamp when the token was issued
- nbf (Not Before): Unix timestamp before which the token must be rejected
- jti (JWT ID): A unique identifier for the token, used to prevent replay attacks
Beyond registered claims, applications add custom claims for their own needs — user roles, permissions, email addresses, tenant IDs. All of these are readable to anyone who holds the token.
How to Decode a JWT
Decoding a JWT — reading its contents without verification — simply requires Base64URL-decoding the header and payload sections. Since JWTs use Base64URL (not standard Base64), you need to substitute - → + and _ → / before passing to a standard decoder:
function decodeJwt(token) {
const [header, payload] = token.split('.');
// Base64URL → Base64 (replace URL-safe chars)
const toBase64 = (str) => str.replace(/-/g, '+').replace(/_/g, '/');
const decodedHeader = JSON.parse(atob(toBase64(header)));
const decodedPayload = JSON.parse(atob(toBase64(payload)));
return { header: decodedHeader, payload: decodedPayload };
}
// Example usage
const { header, payload } = decodeJwt(token);
console.log(header); // { alg: "HS256", typ: "JWT" }
console.log(payload); // { sub: "1234567890", name: "Alice", exp: 1893456000 }
// Check expiration
const isExpired = Date.now() / 1000 > payload.exp;Signature Verification — Why Decoding Is Not Verification
Critical: Never trust a JWT's claims without cryptographically verifying the signature on the server. Decoding reads the data; verification proves it hasn't been tampered with.
Anyone who possesses a JWT can decode and read its payload. The signature is what makes the claims trustworthy — it proves the token was created by a party holding the signing key and that the header and payload have not been modified since signing.
With symmetric algorithms (HS256), the same secret key signs and verifies the token — both the issuer and verifier must hold the secret. With asymmetric algorithms (RS256, ES256), a private key signs the token and a public key verifies it. The public key can be distributed freely; only the private key holder can produce valid tokens.
Critical Security Vulnerabilities
The "alg: none" Attack
The JWT specification allows an alg value of "none" — meaning the token is unsigned. Early JWT libraries would accept a token claiming alg: none and skip signature verification entirely. An attacker could take a valid token, change the payload (escalating their role from "user" to "admin"), set alg to "none", remove the signature, and submit the modified token. The vulnerable server would accept it as legitimate.
Always use a JWT library that explicitly rejects alg: none. Never accept the algorithm specified in the token header — pin the expected algorithm on the server and reject anything that doesn't match.
Weak HMAC Secrets
HS256 requires a strong, random secret key. A short or guessable secret ("secret", "password", the app name, the domain) can be brute-forced offline: an attacker who captures a JWT can attempt billions of HMAC-SHA256 computations per second using a GPU until they find the key. JWT secrets must be at least 256 bits (32 bytes) of cryptographically random data, generated with a CSPRNG.
Token Storage and Expiration Strategy
Where you store JWTs in the browser affects your security model:
- localStorage: Persists across browser sessions, but accessible to JavaScript — any XSS vulnerability exposes the token
- sessionStorage: Cleared when the browser tab closes, but still accessible to JavaScript — same XSS risk as localStorage
- HTTP-only cookies: Not accessible to JavaScript at all — immune to XSS token theft. Requires CSRF protection (SameSite cookie attribute handles most cases)
For expiration, keep access token lifetimes short — 15 minutes to 1 hour is common. Use a refresh token with a longer lifetime (days or weeks) to issue new access tokens silently when they expire. Rotating refresh tokens (issuing a new refresh token on every use and invalidating the old one) provides additional protection against token theft.