At some point you've pasted a JWT into jwt.io to see what's inside, and it decoded instantly. That should tell you something: these tokens aren't secret. They're signed, not encrypted. JWTs show up in OAuth 2.0 flows, OpenID Connect, and Authorization headers of virtually every REST and GraphQL API built in the last decade — and two specific misunderstandings about how they work have caused serious security incidents in production systems that really should have known better.
The Three Parts of a JWT
A JWT is three Base64URL-encoded sections separated by dots: header.payload.signature. That's it. No magic.
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)What's in the payload: registered and custom claims
The JWT payload contains "claims" — key-value pairs that say something about the token's subject. RFC 7519 defines several registered claim names with standard meanings. These are the ones you'll encounter in almost every JWT:
- sub (Subject): who this token is about — typically a user ID
- iss (Issuer): who issued it, usually a URL like https://auth.example.com
- aud (Audience): the intended recipient — your server should verify this matches
- exp (Expiration Time): Unix timestamp after which the token must be rejected
- iat (Issued At): when it was issued
Beyond registered claims, applications add custom claims — user roles, permissions, email addresses, tenant IDs. All of these are readable to anyone who holds the token. Don't put secrets in the payload.
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;Decoding is not the same as verifying — this matters enormously
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. This distinction is not academic — it's the source of real breaches.
Anyone with a JWT can decode and read its payload. The signature is what makes those claims trustworthy — it proves the token was created by a party holding the signing key, and that the header and payload haven't been modified since signing.
With symmetric algorithms (HS256), the same secret key signs and verifies the token — both issuer and verifier must hold the secret. With asymmetric algorithms (RS256, ES256), a private key signs and a public key verifies. The public key can be shared freely; only the private key holder can produce valid tokens. For microservices where multiple services need to verify tokens, RS256 or ES256 is generally the better choice.
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.
Use a JWT library that explicitly rejects alg: none — most modern libraries do this by default, but check. Never accept the algorithm from 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 need at least 256 bits (32 bytes) of random data from a CSPRNG, not from Math.random().
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)
Keep access token lifetimes short — 15 minutes to 1 hour is typical. Use a refresh token with a longer lifetime (days to weeks) to issue new access tokens silently. Rotating refresh tokens (issue a new one on every use, invalidate the old) adds meaningful protection against token theft without requiring users to re-login frequently.