JSON Web Tokens (JWT) are everywhere — used for authentication, API authorization, and session management. But they are also widely misunderstood and frequently misimplemented.
What Is a JWT?
A JWT is a compact, URL-safe token consisting of three Base64URL-encoded parts separated by dots:
header.payload.signature
Header declares the token type and signing algorithm:
{ "alg": "HS256", "typ": "JWT" }
Payload contains the claims — statements about the subject:
{
"sub": "1234567890",
"name": "Alice",
"iat": 1516239022,
"exp": 1516242622
}
Signature verifies the token has not been tampered with:
HMACSHA256(base64(header) + "." + base64(payload), secret)
Standard Claims
| Claim | Meaning |
|---|---|
iss | Issuer — who created the token |
sub | Subject — who the token is about |
aud | Audience — who the token is intended for |
exp | Expiration time (Unix timestamp) |
iat | Issued at time |
jti | JWT ID — unique identifier for the token |
Always validate exp, iss, and aud on the server side.
HS256 vs RS256
HS256 (HMAC-SHA256) uses a shared secret — both the issuer and verifier need the same key. Simple to set up but requires secure key distribution. Appropriate for single-service authentication where the same server issues and verifies tokens.
RS256 (RSA-SHA256) uses an asymmetric key pair. The private key signs, the public key verifies. The public key can be published as a JWKS endpoint. Required for multi-service architectures where different services verify tokens they did not issue.
Common Security Pitfalls
1. The "none" Algorithm Attack
Some early JWT libraries accepted "alg": "none" which skips signature verification entirely. Always explicitly specify allowed algorithms and reject none.
// WRONG — vulnerable to algorithm substitution
jwt.verify(token, secret);
// RIGHT — explicitly allow only expected algorithm
jwt.verify(token, secret, { algorithms: ['HS256'] });
2. Storing JWTs in localStorage
localStorage is accessible via JavaScript and vulnerable to XSS. Prefer httpOnly cookies which are not accessible to JavaScript.
3. Not Validating the Audience
If you have multiple services accepting the same JWT, a token issued for Service A can be replayed against Service B. Always set and validate aud.
4. Long Expiration Times
A JWT cannot be invalidated before its exp without a server-side revocation list — which defeats the stateless advantage. Keep access tokens short-lived (15 minutes) and use refresh tokens for long sessions.
Refresh Token Pattern
1. Client logs in → server issues:
- access_token (15m expiry, httpOnly cookie)
- refresh_token (7d expiry, httpOnly cookie, rotated)
2. Access token expires → client sends refresh_token
→ server validates, issues new access_token + rotates refresh_token
3. Logout → server invalidates refresh_token in database
Decoding Without a Library
function decodeJwt(token) {
const [, payload] = token.split('.');
return JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
}
This decodes without verifying — useful for debugging but never use for security decisions.
Summary
- Use short-lived access tokens + refresh token rotation
- Store in
httpOnlycookies, not localStorage - Always validate
exp,iss,aud - Use RS256 for multi-service architectures
- Never trust
alg: none