JWT Tokens Explained: Authentication for Modern Web Apps
· 12 min read
Table of Contents
- What Is JWT?
- JWT Structure Breakdown
- How JWT Authentication Works
- JWT vs Session-Based Authentication
- Implementing JWT in Your Application
- Security Best Practices
- Common Vulnerabilities and How to Prevent Them
- Token Refresh Strategies
- Where to Store JWTs
- Performance Considerations
- Frequently Asked Questions
- Related Articles
JSON Web Tokens (JWT) have become the de facto standard for authentication in modern web applications. They provide a compact, URL-safe way to represent claims between two parties, enabling stateless authentication that scales horizontally without shared session storage.
Whether you're building a REST API, a microservices architecture, or a single-page application, understanding JWTs is essential for implementing secure, scalable authentication. This comprehensive guide explains how JWTs work, their structure, security considerations, and production-ready best practices.
What Is JWT?
JWT (pronounced "jot") is an open standard (RFC 7519) that defines a compact, self-contained format for securely transmitting information between parties as a JSON object. The information is digitally signed using either a secret key (HMAC) or a public/private key pair (RSA or ECDSA), ensuring the token's integrity and authenticity.
Unlike traditional session-based authentication where the server maintains session state in memory or a database, JWTs are stateless. All the information needed to verify the user's identity is contained within the token itself.
Primary Use Cases for JWT
- Authentication: After a user logs in, the server issues a JWT that the client includes with subsequent requests to prove their identity
- Authorization: JWTs can carry user roles, permissions, and scopes, allowing servers to make granular access control decisions without database lookups
- Information exchange: The signed nature of JWTs ensures the sender is who they claim to be and that the data hasn't been tampered with during transmission
- Single Sign-On (SSO): JWTs enable seamless authentication across multiple domains and services
- API authentication: Mobile apps and third-party integrations can authenticate API requests without maintaining server-side sessions
Pro tip: Use our JWT Decoder to inspect and validate tokens during development. It helps you understand the structure and catch common issues before they reach production.
JWT Structure Breakdown
A JWT consists of three distinct parts separated by dots (.): header.payload.signature
Here's what a complete JWT looks like:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkphbmUgRGV2ZWxvcGVyIiwiZW1haWwiOiJqYW5lQGV4YW1wbGUuY29tIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzEwNTkwNDAwLCJleHAiOjE3MTA2NzY4MDB9.4Adcj0mYZ8s5vxjKvV8pF7jKX9s8vZ5xJ3kL9mN2pQ4
Part 1: Header
The header typically contains two fields that describe the token's cryptographic operations:
{
"alg": "HS256",
"typ": "JWT"
}
algspecifies the signing algorithm (HS256, RS256, ES256, etc.)typdeclares the token type, which is always "JWT"
This JSON object is Base64Url-encoded to form the first part of the JWT. The encoding makes it URL-safe and compact for transmission in HTTP headers.
Part 2: Payload
The payload contains claims—statements about the user and additional metadata. Claims are categorized into three types:
{
"sub": "1234567890",
"name": "Jane Developer",
"email": "[email protected]",
"role": "admin",
"iat": 1710590400,
"exp": 1710676800
}
Registered claims are predefined by the JWT specification and provide standard information:
iss(issuer): Identifies who issued the tokensub(subject): Identifies the subject of the token (usually the user ID)aud(audience): Identifies the intended recipientsexp(expiration time): Unix timestamp when the token expiresnbf(not before): Token is not valid before this timeiat(issued at): When the token was createdjti(JWT ID): Unique identifier for the token
Public claims are custom claims that should be defined in the IANA JSON Web Token Registry or use collision-resistant names (like URLs).
Private claims are custom claims agreed upon between parties, like role, permissions, or organizationId.
Important: The payload is only Base64Url-encoded, not encrypted. Never store sensitive information like passwords, credit card numbers, or social security numbers in a JWT payload.
Part 3: Signature
The signature ensures the token hasn't been altered. It's created by taking the encoded header, encoded payload, a secret key, and applying the algorithm specified in the header:
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
The signature allows the recipient to verify that the sender is who they claim to be and that the message wasn't changed along the way.
Signing Algorithms Comparison
| Algorithm | Type | Security Level | Use Case | Key Management |
|---|---|---|---|---|
| HS256 | Symmetric (HMAC) | Good | Single service, internal APIs | Shared secret |
| RS256 | Asymmetric (RSA) | Very Good | Multiple services, public APIs | Public/private key pair |
| ES256 | Asymmetric (ECDSA) | Excellent | High security requirements | Public/private key pair |
| PS256 | Asymmetric (RSA-PSS) | Excellent | Modern applications | Public/private key pair |
How JWT Authentication Works
Understanding the complete authentication flow helps you implement JWTs correctly and troubleshoot issues effectively.
The Complete Authentication Flow
- User Login: The client sends credentials (username/password) to the authentication endpoint
- Credential Verification: The server validates credentials against the database
- Token Generation: Upon successful authentication, the server creates a JWT containing user information and claims
- Token Delivery: The server sends the JWT back to the client (typically in the response body)
- Token Storage: The client stores the JWT (in memory, localStorage, or httpOnly cookies)
- Authenticated Requests: For subsequent requests, the client includes the JWT in the Authorization header
- Token Verification: The server validates the JWT signature and checks expiration
- Access Granted: If valid, the server processes the request using the claims in the token
Example Authentication Request
POST /api/auth/login HTTP/1.1
Host: api.example.com
Content-Type: application/json
{
"email": "[email protected]",
"password": "securePassword123"
}
Example Authentication Response
HTTP/1.1 200 OK
Content-Type: application/json
{
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"expiresIn": 3600,
"tokenType": "Bearer"
}
Example Authenticated API Request
GET /api/users/profile HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
JWT vs Session-Based Authentication
Choosing between JWT and session-based authentication depends on your application's architecture, scale, and requirements.
| Aspect | JWT | Session-Based |
|---|---|---|
| State | Stateless (server doesn't store tokens) | Stateful (server stores session data) |
| Scalability | Excellent (no shared storage needed) | Requires session store (Redis, database) |
| Revocation | Difficult (requires blacklist or short expiry) | Easy (delete session from store) |
| Size | Larger (sent with every request) | Smaller (just session ID) |
| Cross-domain | Easy (CORS-friendly) | Complex (cookie domain restrictions) |
| Mobile apps | Ideal (no cookie support needed) | Challenging (cookie handling) |
| Security | Requires careful implementation | Well-established patterns |
When to Use JWT
- Building microservices that need to share authentication
- Developing mobile applications or SPAs
- Creating public APIs for third-party integrations
- Implementing Single Sign-On (SSO) across multiple domains
- Scaling horizontally without shared session storage
When to Use Sessions
- Building traditional server-rendered web applications
- Requiring immediate token revocation capabilities
- Working with sensitive data that needs server-side control
- Minimizing bandwidth (sessions use smaller cookies)
- Implementing complex session management features
Implementing JWT in Your Application
Let's walk through practical implementation examples in popular programming languages and frameworks.
Node.js with Express
const jwt = require('jsonwebtoken');
const express = require('express');
const app = express();
const SECRET_KEY = process.env.JWT_SECRET;
// Login endpoint
app.post('/api/auth/login', async (req, res) => {
const { email, password } = req.body;
// Verify credentials (pseudo-code)
const user = await verifyCredentials(email, password);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Generate JWT
const token = jwt.sign(
{
sub: user.id,
email: user.email,
role: user.role
},
SECRET_KEY,
{ expiresIn: '1h' }
);
res.json({ accessToken: token });
});
// Authentication middleware
const authenticateToken = (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
jwt.verify(token, SECRET_KEY, (err, user) => {
if (err) {
return res.status(403).json({ error: 'Invalid token' });
}
req.user = user;
next();
});
};
// Protected route
app.get('/api/users/profile', authenticateToken, (req, res) => {
res.json({ user: req.user });
});
Python with Flask
from flask import Flask, request, jsonify
import jwt
import datetime
import os
app = Flask(__name__)
SECRET_KEY = os.getenv('JWT_SECRET')
@app.route('/api/auth/login', methods=['POST'])
def login():
data = request.get_json()
email = data.get('email')
password = data.get('password')
# Verify credentials
user = verify_credentials(email, password)
if not user:
return jsonify({'error': 'Invalid credentials'}), 401
# Generate JWT
token = jwt.encode({
'sub': user['id'],
'email': user['email'],
'role': user['role'],
'exp': datetime.datetime.utcnow() + datetime.timedelta(hours=1)
}, SECRET_KEY, algorithm='HS256')
return jsonify({'accessToken': token})
def token_required(f):
def decorator(*args, **kwargs):
token = request.headers.get('Authorization')
if not token:
return jsonify({'error': 'No token provided'}), 401
try:
token = token.split(' ')[1]
data = jwt.decode(token, SECRET_KEY, algorithms=['HS256'])
request.user = data
except jwt.ExpiredSignatureError:
return jsonify({'error': 'Token expired'}), 403
except jwt.InvalidTokenError:
return jsonify({'error': 'Invalid token'}), 403
return f(*args, **kwargs)
decorator.__name__ = f.__name__
return decorator
@app.route('/api/users/profile')
@token_required
def profile():
return jsonify({'user': request.user})
Quick tip: Always use environment variables for secret keys. Never hardcode them in your source code or commit them to version control. Use tools like Password Generator to create strong secrets.
Security Best Practices
Implementing JWT securely requires attention to multiple security considerations. Following these best practices helps protect your application from common attacks.
1. Use Strong Signing Algorithms
Always use secure algorithms like RS256 or ES256 for production applications. Avoid the none algorithm entirely—it's a security vulnerability waiting to happen.
Never allow clients to specify the algorithm. Some libraries accept the algorithm from the token header, which attackers can exploit by changing it to none or switching from RS256 to HS256.
2. Keep Tokens Short-Lived
Access tokens should expire quickly (15-30 minutes) to limit the damage if compromised. Use refresh tokens for longer sessions.
const accessToken = jwt.sign(payload, SECRET, { expiresIn: '15m' });
const refreshToken = jwt.sign(payload, REFRESH_SECRET, { expiresIn: '7d' });
3. Validate All Claims
Don't just verify the signature—validate all critical claims:
- Check
exp(expiration) to ensure the token hasn't expired - Verify
iss(issuer) matches your expected issuer - Validate
aud(audience) matches your application - Check
nbf(not before) if you use it
4. Use HTTPS Everywhere
Always transmit JWTs over HTTPS. Without encryption, tokens can be intercepted through man-in-the-middle attacks.
5. Implement Token Rotation
Rotate refresh tokens after each use to detect token theft. If a refresh token is used twice, it indicates potential compromise.
6. Store Secrets Securely
Use environment variables, secret management services (AWS Secrets Manager, HashiCorp Vault), or key management systems. Never commit secrets to version control.
7. Implement Rate Limiting
Protect authentication endpoints with rate limiting to prevent brute force attacks:
const rateLimit = require('express-rate-limit');
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 requests per window
message: 'Too many login attempts, please try again later'
});
app.post('/api/auth/login', loginLimiter, loginHandler);
8. Add Security Headers
Use security headers to protect against common attacks:
app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('X-XSS-Protection', '1; mode=block');
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
next();
});
Common Vulnerabilities and How to Prevent Them
Understanding common JWT vulnerabilities helps you build more secure applications. Here are the most critical issues and their solutions.
1. Algorithm Confusion Attack
The vulnerability: Attackers change the algorithm from RS256 (asymmetric) to HS256 (symmetric) and sign the token with the public key, which the server then uses as the HMAC secret.
Prevention:
// Explicitly specify the expected algorithm
jwt.verify(token, publicKey, { algorithms: ['RS256'] });
2. None Algorithm Attack
The vulnerability: Attackers set the algorithm to "none" and remove the signature, bypassing verification entirely.
Prevention:
// Never allow 'none' algorithm
jwt.verify(token, secret, {
algorithms: ['HS256', 'RS256'],
// 'none' is automatically rejected
});
3. Weak Secret Keys
The vulnerability: Using short or predictable secrets allows attackers to brute force the signing key.
Prevention: Use cryptographically strong secrets (at least 256 bits for HS256):
// Generate a strong secret
const crypto = require('crypto');
const secret = crypto.randomBytes(32).toString('hex');
4. Token Leakage
The vulnerability: Tokens exposed in URLs, logs, or browser history can be stolen.
Prevention:
- Never pass tokens in URL query parameters
- Use Authorization headers instead
- Sanitize logs to remove tokens
- Implement proper CORS policies
5. Missing Expiration Validation
The vulnerability: Tokens without expiration or improper validation allow indefinite access.
Prevention:
// Always set expiration
const token = jwt.sign(payload, secret, { expiresIn: '15m' });
// Verify expiration is checked
jwt.verify(token, secret, {
clockTolerance: 0 // No tolerance for expired tokens
});
6. Cross-Site Scripting (XSS)
The vulnerability: If tokens are stored in localStorage, XSS attacks can steal them.
Prevention:
- Store tokens in httpOnly cookies when possible
- Implement Content Security Policy (CSP)
- Sanitize all user input
- Use modern frameworks with built-in XSS protection
7. Cross-Site Request Forgery (CSRF)
The vulnerability: When using cookies, attackers can trigger authenticated requests from malicious sites.
Prevention:
- Use SameSite cookie attribute
- Implement CSRF tokens for state-changing operations
- Verify Origin and Referer headers
res.cookie('token', jwt, {
httpOnly: true,
secure: true,
sameSite: 'strict'
});
Token Refresh Strategies
Implementing a robust token refresh mechanism balances security with user experience. Users shouldn't need to log in constantly, but tokens shouldn't live forever.
The Refresh Token Pattern
Use two types of tokens:
- Access Token: Short-lived (15-30 minutes), used for API requests
- Refresh Token: Long-lived (7-30 days), used only to obtain new access tokens
Implementation Example
// Generate both tokens on login
app.post('/api/auth/login', async (req, res) => {
const user = await authenticateUser(req.body);
const accessToken = jwt.sign(
{ sub: user.id, email: user.email },
ACCESS_SECRET,
{ expiresIn: '15m' }
);
const refreshToken = jwt.sign(
{ sub: user.id, type: 'refresh' },
REFRESH_SECRET,
{ expiresIn: '7d' }
);
// Store refresh token in database
await storeRefreshToken(user.id, refreshToken);
res.json({ accessToken, refreshToken });
});
// Refresh endpoint
app.post('/api/auth/refresh', async (req, res) => {
const { refreshToken } = req.body;
try {
const decoded = jwt.verify(refreshToken, REFRESH_SECRET);
// Verify token exists in database
const isValid = await verifyRefreshToken(decoded.sub, refreshToken);
if (!isValid) {
return res.status(403).json({ error: 'Invalid refresh token' });
}
// Generate new access token
const accessToken = jwt.sign(
{ sub: decoded.sub },
ACCESS_SECRET,
{ expiresIn: '15m' }
);
// Optional: Rotate refresh token
const newRefreshToken = jwt.sign(
{ sub: decoded.sub, type: 'refresh' },
REFRESH_SECRET,
{ expiresIn: '7d' }
);
await rotateRefreshToken(decoded.sub, refreshToken, newRefreshToken);
res.json({ accessToken, refreshToken: newRefreshToken });
} catch (error) {
res.status(403).json({ error: 'Invalid refresh token' });
}
});
Automatic Token Refresh
Implement automatic refresh in your client application:
// JavaScript client example
let accessToken = localStorage.getItem('accessToken');
let refreshToken = localStorage.getItem('refreshToken');
async function refreshAccessToken() {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken })
});
const data = await response.json();
accessToken = data.accessToken;
refreshToken = data.refreshToken;
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
}
// Intercept 401 responses and refresh
async function apiRequest(url, options = {}) {
options.headers = {
...options.headers,
'Authorization': `Bearer ${accessToken}`
};
let response = await fetch(url, options);
if (response.status === 401) {
await refreshAccessToken();
options.headers['Authorization'] = `Bearer ${accessToken}`;
response = await fetch(url, options);
}
return response;
}
Pro tip: Refresh tokens proactively before access tokens expire (e.g., when 80% of the lifetime has passed) to avoid interrupting user workflows.
Where to Store JWTs
Choosing where to store JWTs significantly impacts your application's security posture. Each option has trade-offs.
Storage Options Comparison
1. localStorage / sessionStorage
Pros:
- Easy to implement
- Works across all browsers
- Accessible from JavaScript
- Persists across page reloads (localStorage)
Cons:
- Vulnerable to XSS attacks
- Accessible to any JavaScript code
- Not automatically sent with requests
Best for: Low-security applications, development environments
2. httpOnly Cookies
Pros:
- Not accessible to JavaScript (XSS protection)
- Automatically sent with requests
- Can use Secure and SameSite flags
- Browser handles storage
Cons:
- Vulnerable to CSRF attacks (requires mitigation)
- Cookie size limits
- Complex CORS setup
- Doesn't work well with mobile apps
Best for: Traditional web applications, high-security requirements
3. Memory (JavaScript variable)
Pros:
- Most secure (lost on page refresh)
- Not vulnerable to XSS or CSRF
- No storage limits
Cons:
- Lost on