JSON Web Tokens (JWTs) are the backbone of modern authentication. They enable stateless, scalable auth by embedding user data directly in a cryptographically signed token. This guide covers JWT structure, security best practices, and common pitfalls to avoid.
Key Takeaways
- 1JWT = Header.Payload.Signature, each Base64URL-encoded; signed but not encrypted by default
- 2Use RS256/ES256 in production (asymmetric); HS256 only for simple single-server setups
- 3Short access tokens (15-30 min) + long-lived refresh tokens in HttpOnly cookies
- 4Always validate: exp, iss, aud claims + whitelist algorithms (never trust token’s alg header)
- 5JWTs are stateless; for revocation, track jti in Redis or use short expiry
1What Is a JWT?
A JWT is a compact, URL-safe token that contains claims (statements about a user or entity) encoded as JSON. It's digitally signed to ensure integrity and optionally encrypted for confidentiality.
JWT Structure: Header.Payload.Signature
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ← Header (Base64URL)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ← Payload (Base64URL)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ← Signature| Part | Contains | Encoded As |
|---|---|---|
| Header | Algorithm & token type | Base64URL JSON |
| Payload | Claims (user data, expiry) | Base64URL JSON |
| Signature | HMAC or RSA signature | Base64URL binary |
JWTs are encoded, not encrypted! Anyone can decode a JWT and read its payload. Never put sensitive data (passwords, credit cards) in a JWT unless using JWE (encrypted JWTs).
The JWT Header
The header specifies the token type and signing algorithm. It's Base64URL-encoded JSON.
{
"alg": "HS256",
"typ": "JWT"
}
// Common algorithms:
// HS256 - HMAC with SHA-256 (symmetric)
// RS256 - RSA with SHA-256 (asymmetric)
// ES256 - ECDSA with SHA-256 (asymmetric)Prefer RS256 or ES256 for production. Asymmetric algorithms let you verify tokens without exposing the signing key. HS256 requires the same secret for signing and verification.
3The JWT Payload (Claims)
The payload contains claims—statements about the user and token metadata. There are three types of claims.
| Claim Type | Description | Examples |
|---|---|---|
| Registered | Standard claims (RFC 7519) | iss, sub, exp, iat, aud |
| Public | Defined in IANA registry | name, email, picture |
| Private | Custom app-specific claims | role, org_id, permissions |
{
"sub": "user_123", // Subject (user ID)
"name": "John Doe", // Public claim
"email": "john@example.com",
"role": "admin", // Private claim
"iat": 1704067200, // Issued at (Unix timestamp)
"exp": 1704153600, // Expires at (Unix timestamp)
"iss": "https://auth.example.com", // Issuer
"aud": "https://api.example.com" // Audience
}| Claim | Full Name | Purpose |
|---|---|---|
| iss | Issuer | Who created/signed the token |
| sub | Subject | User or entity ID |
| aud | Audience | Intended recipient (your API) |
| exp | Expiration | Token expiry (Unix timestamp) |
| iat | Issued At | When token was created |
| nbf | Not Before | Token not valid before this time |
| jti | JWT ID | Unique token identifier (for revocation) |
4The JWT Signature
The signature ensures the token hasn't been tampered with. It's created by signing the encoded header and payload.
// How signature is created (HS256 example)
signature = HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
// Verification process:
// 1. Split token into header.payload.signature
// 2. Recompute signature using header.payload + secret
// 3. Compare computed signature with provided signature
// 4. If match: token is valid and unmodifiedNever accept "alg": "none"! This disables signature verification. Many JWT libraries have been vulnerable to this attack. Always validate the algorithm server-side.
Decode & Inspect JWTs
Paste any JWT to see its decoded header, payload, and signature.
Open JWT Decoder5JWT Authentication Flow
Here's how JWT-based authentication typically works in a web application.
- 1User logs in with credentials (username/password, OAuth, etc.)
- 2Server validates credentials against database
- 3Server creates JWT with user claims and signs it
- 4Server returns JWT to client (in response body or cookie)
- 5Client stores JWT (localStorage, sessionStorage, or cookie)
- 6Client sends JWT with each API request (Authorization header)
- 7Server validates JWT signature and expiration
- 8Server extracts claims and processes request
// Client: Send JWT with requests
fetch('/api/protected', {
headers: {
'Authorization': 'Bearer eyJhbGciOiJIUzI1NiIs...'
}
});
// Server: Validate and extract claims (Node.js example)
import jwt from 'jsonwebtoken';
function authenticate(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: 'No token' });
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded; // { sub: "user_123", role: "admin", ... }
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid token' });
}
}6Access Tokens vs Refresh Tokens
Production systems typically use two types of tokens with different lifespans.
| Aspect | Access Token | Refresh Token |
|---|---|---|
| Purpose | Authorize API requests | Get new access tokens |
| Lifespan | Short (15 min - 1 hour) | Long (days - weeks) |
| Storage | Memory or short-lived cookie | HttpOnly secure cookie |
| Sent to | Every API request | Token refresh endpoint only |
| If stolen | Limited damage window | Can be revoked server-side |
// Token refresh flow
async function refreshAccessToken() {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include' // Send refresh token cookie
});
if (response.ok) {
const { accessToken } = await response.json();
// Store new access token
return accessToken;
}
// Refresh failed - redirect to login
window.location.href = '/login';
}Short access token expiry (15-30 min) limits damage if a token is stolen. The refresh token, stored securely, allows users to stay logged in without re-entering credentials.
7JWT Security Best Practices
JWTs are powerful but require careful implementation. Follow these security guidelines.
- Use strong secrets: 256+ bits of entropy for HMAC keys
- Set short expiration: Access tokens 15-60 min max
- Validate all claims: Check exp, iss, aud server-side
- Never trust
- from token: Whitelist allowed algorithms
- Use HTTPS only: Prevent token interception in transit
- Store securely: HttpOnly cookies for refresh tokens, not localStorage
- Implement revocation: Track token IDs (jti) for logout/compromise
// Secure JWT verification (Node.js)
const decoded = jwt.verify(token, secret, {
algorithms: ['HS256'], // Whitelist algorithms (prevent "none")
issuer: 'https://auth.myapp.com', // Validate issuer
audience: 'https://api.myapp.com', // Validate audience
clockTolerance: 30 // Allow 30s clock skew
});
// Check for revocation (using jti claim)
const isRevoked = await checkTokenRevocationList(decoded.jti);
if (isRevoked) throw new Error('Token revoked');localStorage is vulnerable to XSS attacks. If an attacker injects JavaScript, they can steal tokens. Use HttpOnly cookies for refresh tokens and consider in-memory storage for access tokens.
8Common JWT Mistakes
These security mistakes appear frequently in JWT implementations.
| Mistake | Risk | Fix |
|---|---|---|
| Long-lived access tokens | Extended exposure window | Use 15-30 min expiry + refresh tokens |
| Weak/hardcoded secrets | Token forgery | Use 256-bit random secrets from env vars |
| Not validating exp | Expired tokens accepted | Always check expiration server-side |
| Trusting alg header | "none" algorithm bypass | Whitelist algorithms in verification |
| Storing in localStorage | XSS token theft | Use HttpOnly cookies for sensitive tokens |
| Not checking audience | Token misuse across apps | Validate aud matches your API |
| No revocation mechanism | Can't logout compromised users | Track jti in Redis/DB for revocation |
Example: Algorithm Confusion Attack
Scenario
Attacker changes alg from RS256 to HS256 and signs with public key
Solution
Never accept algorithm from token. Hardcode expected algorithm: jwt.verify(token, key, { algorithms: ['RS256'] })
Frequently Asked Questions
What is the difference between JWT, JWS, and JWE?
JWT is the umbrella term for JSON Web Tokens. JWS (JSON Web Signature) is a signed JWT—the most common type—where the payload is readable but tamper-proof. JWE (JSON Web Encryption) is an encrypted JWT where the payload is also confidential. Most ’JWTs’ are actually JWS tokens.
Where should I store JWTs on the client?
For access tokens: in-memory (JavaScript variable) is most secure against XSS. For refresh tokens: HttpOnly, Secure, SameSite=Strict cookies prevent XSS theft. Avoid localStorage for sensitive tokens as it’s accessible to any JavaScript on the page.
How do I revoke a JWT before it expires?
JWTs are stateless by design, so immediate revocation requires server-side tracking. Options: 1) Keep a revocation list of jti (token IDs) in Redis/database. 2) Use short expiry (15 min) so revocation happens naturally. 3) Store a ’valid since’ timestamp per user and reject older tokens.
Should I use HS256 or RS256?
RS256 (asymmetric) is preferred for production: the private key signs tokens, public key verifies them. This means verification can happen without exposing the signing secret. HS256 (symmetric) uses the same secret for both, so anyone who can verify can also forge tokens. Use HS256 only for simple single-server setups.
Why does my JWT contain user data if it can be decoded?
JWTs are signed, not encrypted (unless using JWE). The signature prevents tampering but not reading. This is by design: it allows clients to read user info without API calls. Never put sensitive data (passwords, payment info) in a JWT. Use claims like user ID, email, and roles that are safe to expose.