JWT (JSON Web Tokens): Structure, Security, and Best Practices
JSON Web Tokens (JWTs) are the backbone of modern authentication and authorization in web applications and APIs. They provide a compact, self-contained way to transmit claims between parties as a JSON object. Understanding JWT internals is critical for building secure systems and performing well in system design interviews.
JWT Structure
A JWT consists of three Base64URL-encoded parts separated by dots: header.payload.signature
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiJ1c2VyMTIzIiwibmFtZSI6IkphbmUgRG9lIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNjk5OTk2NDAwLCJleHAiOjE3MDAwMDAwMDB9.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Header
The header specifies the token type and signing algorithm:
{
"alg": "RS256", // Signing algorithm
"typ": "JWT", // Token type
"kid": "key-2024" // Key ID (for key rotation)
}
Payload (Claims)
The payload contains claims — statements about the user and metadata:
{
"iss": "https://auth.myapp.com", // Issuer
"sub": "user123", // Subject (user ID)
"aud": "https://api.myapp.com", // Audience
"exp": 1700000000, // Expiration (Unix timestamp)
"iat": 1699996400, // Issued at
"nbf": 1699996400, // Not before
"jti": "unique-token-id-789", // JWT ID (prevents replay)
"name": "Jane Doe", // Custom claim
"role": "admin", // Custom claim
"permissions": ["read", "write"] // Custom claim
}
Registered Claims Reference
| Claim | Name | Purpose | Required? |
|---|---|---|---|
| iss | Issuer | Who created the token | Recommended |
| sub | Subject | Who the token is about | Recommended |
| aud | Audience | Intended recipient | Recommended |
| exp | Expiration | When the token expires | Required |
| iat | Issued At | When the token was created | Recommended |
| jti | JWT ID | Unique ID to prevent replay | Optional |
Signature
The signature verifies the token was not tampered with. It is created by signing the encoded header and payload with a secret or private key:
// For HMAC (HS256):
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
// For RSA (RS256):
RSASHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
privateKey
)
Signing Algorithms
| Algorithm | Type | Key | Best For |
|---|---|---|---|
| HS256 | Symmetric (HMAC) | Shared secret | Single-service apps |
| RS256 | Asymmetric (RSA) | Private/public key pair | Microservices, distributed systems |
| ES256 | Asymmetric (ECDSA) | Private/public key pair | Mobile apps, performance-critical |
| PS256 | Asymmetric (RSA-PSS) | Private/public key pair | High-security requirements |
For microservice architectures, prefer asymmetric algorithms (RS256 or ES256). The auth service signs tokens with a private key, and any service can verify with the public key — no shared secret distribution needed.
Creating and Verifying JWTs
Creating a JWT (Node.js)
const jwt = require('jsonwebtoken');
const fs = require('fs');
// Using RS256 (asymmetric)
const privateKey = fs.readFileSync('./keys/private.pem');
function generateAccessToken(user) {
return jwt.sign(
{
sub: user.id,
name: user.name,
role: user.role,
permissions: user.permissions
},
privateKey,
{
algorithm: 'RS256',
expiresIn: '15m',
issuer: 'https://auth.myapp.com',
audience: 'https://api.myapp.com',
jwtid: crypto.randomUUID()
}
);
}
function generateRefreshToken(user) {
return jwt.sign(
{ sub: user.id, type: 'refresh' },
privateKey,
{
algorithm: 'RS256',
expiresIn: '7d',
issuer: 'https://auth.myapp.com',
jwtid: crypto.randomUUID()
}
);
}
Verifying a JWT
const publicKey = fs.readFileSync('./keys/public.pem');
function verifyAccessToken(token) {
try {
const decoded = jwt.verify(token, publicKey, {
algorithms: ['RS256'], // CRITICAL: Whitelist algorithms
issuer: 'https://auth.myapp.com',
audience: 'https://api.myapp.com',
clockTolerance: 30 // 30 seconds clock skew tolerance
});
return { valid: true, payload: decoded };
} catch (error) {
if (error.name === 'TokenExpiredError') {
return { valid: false, error: 'Token expired' };
}
if (error.name === 'JsonWebTokenError') {
return { valid: false, error: 'Invalid token' };
}
return { valid: false, error: 'Verification failed' };
}
}
// Express middleware
function authenticateJWT(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing token' });
}
const token = authHeader.split(' ')[1];
const result = verifyAccessToken(token);
if (!result.valid) {
return res.status(401).json({ error: result.error });
}
req.user = result.payload;
next();
}
Access Tokens vs Refresh Tokens
| Feature | Access Token | Refresh Token |
|---|---|---|
| Purpose | Authorize API requests | Get new access tokens |
| Lifetime | 15 minutes (typical) | 7-30 days |
| Contents | User claims, roles, permissions | Minimal (user ID, token type) |
| Storage (Browser) | In-memory variable | HttpOnly secure cookie |
| Revocation | Difficult (short expiry mitigates) | Server-side blocklist |
Token Rotation Pattern
// Refresh endpoint with token rotation
app.post('/auth/refresh', async (req, res) => {
const { refreshToken } = req.cookies;
if (!refreshToken) return res.status(401).json({ error: 'No refresh token' });
// Check if token is in the blocklist (was already rotated)
const isRevoked = await redis.get(`revoked:${refreshToken}`);
if (isRevoked) {
// Possible token theft — revoke entire family
await revokeAllUserTokens(isRevoked);
return res.status(401).json({ error: 'Token reuse detected' });
}
try {
const decoded = jwt.verify(refreshToken, publicKey, {
algorithms: ['RS256']
});
// Revoke the old refresh token
await redis.set(`revoked:${refreshToken}`, decoded.sub, 'EX', 86400 * 30);
// Issue new token pair
const newAccessToken = generateAccessToken(decoded);
const newRefreshToken = generateRefreshToken(decoded);
res.cookie('refreshToken', newRefreshToken, {
httpOnly: true, secure: true, sameSite: 'strict', maxAge: 7 * 86400 * 1000
});
res.json({ accessToken: newAccessToken });
} catch (err) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
});
Security Pitfalls and Mitigations
1. The "none" Algorithm Attack
Attackers modify the header to use "alg": "none", bypassing signature verification entirely.
// VULNERABLE — accepts any algorithm
jwt.verify(token, secret); // DO NOT DO THIS
// SECURE — whitelist specific algorithms
jwt.verify(token, publicKey, { algorithms: ['RS256'] });
2. Algorithm Confusion Attack
An attacker changes RS256 to HS256 and signs with the public key (which is publicly available). If the server uses the same public key to verify HMAC, the forged token passes verification.
Mitigation: Always specify the expected algorithm explicitly. Never allow the token to dictate the verification algorithm.
3. Weak Signing Secrets
// WEAK — easily brute-forced
const secret = 'password123';
// STRONG — 256+ bit random secret
const secret = crypto.randomBytes(64).toString('hex');
// Or use asymmetric keys (RS256/ES256) instead
4. Storing Sensitive Data in Payload
JWT payloads are Base64URL-encoded, not encrypted. Anyone can decode and read them. Never include passwords, credit card numbers, or other sensitive data in JWT claims. Use our Security Crypto Tools to decode and inspect JWT tokens.
5. Token Size Issues
Every claim increases token size. JWTs are sent with every request in the Authorization header. Large tokens impact performance, especially on mobile networks. Keep tokens under 1KB by including only essential claims.
JWT vs Session-Based Authentication
| Feature | JWT (Stateless) | Sessions (Stateful) |
|---|---|---|
| Server storage | None (self-contained) | Session store (Redis, DB) |
| Scalability | Excellent (no shared state) | Requires sticky sessions or shared store |
| Revocation | Difficult (need blocklist) | Easy (delete session) |
| Cross-domain | Easy (Bearer header) | Complex (cookie domain issues) |
| Best for | APIs, microservices, SPAs | Server-rendered apps, simple setups |
In microservice architectures, JWTs are generally preferred because each service can independently verify tokens without calling a central session store. For OAuth 2.0 flows, JWTs are the standard format for access tokens and ID tokens.
Best Practices Summary
- Always whitelist algorithms — never let the token header dictate verification
- Use RS256 or ES256 for distributed systems; HS256 only for single-service apps
- Set short expiration times (15 minutes for access tokens)
- Implement refresh token rotation with reuse detection
- Store access tokens in memory, refresh tokens in HttpOnly secure cookies
- Validate all registered claims: iss, aud, exp, nbf
- Keep token payloads minimal — avoid bloating with unnecessary claims
- Implement rate limiting on token endpoints to prevent abuse
- Use key rotation with
kid(Key ID) header parameter - Never store secrets or sensitive data in JWT payloads
Explore more about securing your APIs with our API Security guide and try tools at swehelper.com/tools.
Frequently Asked Questions
Are JWTs encrypted?
Standard JWTs (JWS — JSON Web Signature) are signed but not encrypted. The payload is Base64URL-encoded, which is trivially decodable. If you need encrypted tokens, use JWE (JSON Web Encryption), but this is less common. The signature only ensures integrity and authenticity, not confidentiality. Never put sensitive data in a standard JWT. For encryption concepts, see our Encryption guide.
How do I revoke a JWT before it expires?
Since JWTs are stateless, you cannot truly revoke them without introducing server-side state. Common approaches: (1) Keep tokens short-lived (15 minutes) so revocation is less critical. (2) Maintain a server-side blocklist of revoked token JTIs in Redis with TTL matching token expiry. (3) Use refresh token rotation — when a refresh token is reused, revoke the entire token family.
Should I use JWTs for session management?
JWTs work well for API authentication in distributed systems. For traditional server-rendered web apps, server-side sessions with a session ID cookie are often simpler and more secure (easier revocation, smaller cookie size). Use JWTs when you need stateless verification across multiple services, cross-domain authentication, or when building APIs for mobile or SPA clients.
What is the maximum size for a JWT?
There is no protocol-defined limit, but practical limits matter. HTTP headers are typically limited to 8KB. Most web servers default to 4-8KB header limits. Aim to keep JWTs under 1KB for optimal performance, especially on mobile networks where every byte counts. If your token exceeds 2KB, reconsider what claims you are including.