JWT Authentication: How JSON Web Tokens Work
· 12 min read
Table of Contents
- What is a JSON Web Token?
- JWT Structure Explained
- How JWT Authentication Works
- Access Tokens vs Refresh Tokens
- Node.js Implementation Guide
- Where to Store JWTs Securely
- Security Best Practices
- Common JWT Vulnerabilities
- Choosing the Right Algorithm
- JWT in Distributed Systems
- Frequently Asked Questions
- Related Articles
What is a JSON Web Token?
A JSON Web Token (JWT) is a compact, URL-safe way to represent claims between two parties. JWTs have become the de facto standard for authentication and authorization in modern web applications, APIs, and microservices architectures.
When a user logs in, the server generates a JWT containing user information and sends it to the client. The client then includes this token in subsequent requests to prove their identity. Think of it like a digital passport that contains your credentials and can be verified without calling back to the issuing authority every time.
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 token is contained within the token itself. This architectural decision brings several advantages:
- Scalability: No need for session storage or sticky sessions in load-balanced environments
- Cross-domain authentication: JWTs work seamlessly across different domains and services
- Mobile-friendly: Perfect for mobile apps that need to authenticate with backend APIs
- Microservices: Each service can independently verify tokens without a central session store
- Performance: Eliminates database lookups for every authenticated request
You can inspect and decode any JWT using our JWT Decoder tool to see its contents without needing to write any code. This is particularly useful when debugging authentication issues or understanding what data your tokens contain.
Pro tip: JWTs are signed, not encrypted by default. Anyone can decode and read the contents of a JWT. Never store sensitive information like passwords, credit card numbers, or social security numbers in JWT payloads.
JWT Structure Explained
A JWT consists of three distinct parts separated by dots (.): the Header, the Payload, and the Signature. Here's what a real JWT looks like:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiZW1haWwiOiJqb2huQGV4YW1wbGUuY29tIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE1MTYyNDI2MjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Let's break down each component to understand how they work together to create a secure, verifiable token.
1. Header
The header typically contains two critical fields that define how the token should be processed:
{
"alg": "HS256",
"typ": "JWT"
}
alg(algorithm): Specifies the cryptographic algorithm used to sign the tokentyp(type): Declares that this is a JWT token
This JSON object is then Base64Url-encoded to form the first part of the JWT. The encoding makes it URL-safe and compact for transmission in HTTP headers.
2. Payload
The payload contains claims — statements about the user and additional metadata. This is where you store the actual data you want to transmit:
{
"sub": "1234567890",
"name": "John Doe",
"email": "[email protected]",
"role": "admin",
"iat": 1516239022,
"exp": 1516242622
}
Claims are divided into three categories:
Registered claims (standardized and recommended):
sub(subject): Unique identifier for the useriat(issued at): Timestamp when the token was createdexp(expiration): Timestamp when the token expiresiss(issuer): Identifies who issued the tokenaud(audience): Identifies who the token is intended fornbf(not before): Token is not valid before this timestampjti(JWT ID): Unique identifier for the token itself
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 department.
3. Signature
The signature is what makes JWTs secure and tamper-proof. 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 serves two critical purposes:
- Integrity verification: Ensures the token hasn't been modified since it was issued
- Authentication: Proves the token was created by someone with access to the secret key
If anyone tries to modify the header or payload, the signature verification will fail, and the token will be rejected.
How JWT Authentication Works
Understanding the complete authentication flow is essential for implementing JWTs correctly. Here's the step-by-step process:
Step 1: User Login
The user submits their credentials (username and password) to the authentication endpoint. The server validates these credentials against the database.
Step 2: Token Generation
If credentials are valid, the server generates a JWT containing user information and claims. The server signs the token using a secret key (for symmetric algorithms) or a private key (for asymmetric algorithms).
Step 3: Token Delivery
The server sends the JWT back to the client, typically in the response body. The client stores this token for future requests.
Step 4: Authenticated Requests
For subsequent requests, the client includes the JWT in the Authorization header using the Bearer schema:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Step 5: Token Verification
The server receives the request, extracts the JWT from the header, and verifies the signature using the secret or public key. If valid, the server extracts user information from the payload and processes the request.
Step 6: Token Expiration
When the token expires, the client must obtain a new token, either by re-authenticating or using a refresh token (covered in the next section).
Quick tip: Always validate the exp claim on the server side, even if you check expiration on the client. Client-side checks can be bypassed, but server-side validation is authoritative.
Access Tokens vs Refresh Tokens
A robust JWT authentication system typically uses two types of tokens working together: access tokens and refresh tokens. Understanding the distinction is crucial for building secure applications.
Access Tokens
Access tokens are short-lived JWTs (typically 15 minutes to 1 hour) that grant access to protected resources. They contain user identity and permissions needed to authorize requests.
Characteristics:
- Short expiration time (5-60 minutes)
- Included in every API request
- Contains user permissions and roles
- Cannot be revoked before expiration (stateless)
Refresh Tokens
Refresh tokens are long-lived tokens (days to months) used exclusively to obtain new access tokens. They're stored securely and only sent to the token refresh endpoint.
Characteristics:
- Long expiration time (days to months)
- Only sent to the refresh endpoint
- Can be revoked in the database
- Often stored with additional metadata (device, IP, user agent)
Why Use Both?
This dual-token approach balances security and user experience:
| Aspect | Access Token Only | Access + Refresh Tokens |
|---|---|---|
| Security | Lower (long-lived tokens at risk) | Higher (short-lived access tokens) |
| User Experience | Better (no re-authentication) | Better (seamless token refresh) |
| Revocation | Difficult (stateless) | Possible (refresh tokens in DB) |
| Network Overhead | Lower | Slightly higher (refresh requests) |
| Implementation Complexity | Simple | Moderate |
Token Refresh Flow
- Client detects access token is expired or about to expire
- Client sends refresh token to
/auth/refreshendpoint - Server validates refresh token against database
- Server generates new access token (and optionally new refresh token)
- Client receives and stores new tokens
- Client retries original request with new access token
Pro tip: Implement refresh token rotation — issue a new refresh token each time one is used and invalidate the old one. This limits the damage if a refresh token is compromised.
Node.js Implementation Guide
Let's build a complete JWT authentication system using Node.js, Express, and the jsonwebtoken library. This implementation includes both access and refresh tokens.
Installation
npm install express jsonwebtoken bcrypt dotenv
Environment Configuration
Create a .env file with your secret keys:
ACCESS_TOKEN_SECRET=your-super-secret-access-key-change-this
REFRESH_TOKEN_SECRET=your-super-secret-refresh-key-change-this
ACCESS_TOKEN_EXPIRY=15m
REFRESH_TOKEN_EXPIRY=7d
Token Generation
const jwt = require('jsonwebtoken');
function generateAccessToken(user) {
return jwt.sign(
{
userId: user.id,
email: user.email,
role: user.role
},
process.env.ACCESS_TOKEN_SECRET,
{ expiresIn: process.env.ACCESS_TOKEN_EXPIRY }
);
}
function generateRefreshToken(user) {
return jwt.sign(
{ userId: user.id },
process.env.REFRESH_TOKEN_SECRET,
{ expiresIn: process.env.REFRESH_TOKEN_EXPIRY }
);
}
Login Endpoint
const bcrypt = require('bcrypt');
app.post('/auth/login', async (req, res) => {
try {
const { email, password } = req.body;
// Find user in database
const user = await User.findOne({ email });
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Verify password
const validPassword = await bcrypt.compare(password, user.password);
if (!validPassword) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Generate tokens
const accessToken = generateAccessToken(user);
const refreshToken = generateRefreshToken(user);
// Store refresh token in database
await RefreshToken.create({
token: refreshToken,
userId: user.id,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
});
res.json({
accessToken,
refreshToken,
user: {
id: user.id,
email: user.email,
name: user.name
}
});
} catch (error) {
res.status(500).json({ error: 'Server error' });
}
});
Authentication Middleware
function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'Access token required' });
}
jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, (err, user) => {
if (err) {
return res.status(403).json({ error: 'Invalid or expired token' });
}
req.user = user;
next();
});
}
// Usage
app.get('/api/protected', authenticateToken, (req, res) => {
res.json({
message: 'Protected data',
user: req.user
});
});
Token Refresh Endpoint
app.post('/auth/refresh', async (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(401).json({ error: 'Refresh token required' });
}
try {
// Verify refresh token exists in database
const storedToken = await RefreshToken.findOne({ token: refreshToken });
if (!storedToken) {
return res.status(403).json({ error: 'Invalid refresh token' });
}
// Verify token signature
jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET, async (err, user) => {
if (err) {
return res.status(403).json({ error: 'Invalid refresh token' });
}
// Get user data
const userData = await User.findById(user.userId);
// Generate new access token
const accessToken = generateAccessToken(userData);
res.json({ accessToken });
});
} catch (error) {
res.status(500).json({ error: 'Server error' });
}
});
Logout Endpoint
app.post('/auth/logout', async (req, res) => {
const { refreshToken } = req.body;
try {
// Remove refresh token from database
await RefreshToken.deleteOne({ token: refreshToken });
res.json({ message: 'Logged out successfully' });
} catch (error) {
res.status(500).json({ error: 'Server error' });
}
});
You can test your JWT implementation using our JWT Decoder to verify the token structure and our API Tester to test your authentication endpoints.
Where to Store JWTs Securely
Token storage is one of the most debated aspects of JWT implementation. The wrong choice can expose your application to serious security vulnerabilities.
Storage Options Compared
| Storage Method | XSS Vulnerable | CSRF Vulnerable | Accessible to JS | Best For |
|---|---|---|---|---|
| localStorage | Yes | No | Yes | Low-risk applications |
| sessionStorage | Yes | No | Yes | Single-tab applications |
| Memory (variable) | Yes | No | Yes | High-security SPAs |
| HttpOnly Cookie | No | Yes | No | Traditional web apps |
| Secure Cookie | No | Yes (mitigated) | No | Production applications |
Recommended Approach
For most applications, use this hybrid approach:
- Access tokens: Store in memory (JavaScript variable) for maximum security
- Refresh tokens: Store in HttpOnly, Secure, SameSite cookies
This approach protects against both XSS (access tokens not in localStorage) and CSRF (SameSite cookies) attacks.
Cookie Configuration
res.cookie('refreshToken', refreshToken, {
httpOnly: true, // Not accessible via JavaScript
secure: true, // Only sent over HTTPS
sameSite: 'strict', // CSRF protection
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
});
Memory Storage Pattern
When storing access tokens in memory, implement automatic refresh before expiration:
let accessToken = null;
async function refreshAccessToken() {
const response = await fetch('/auth/refresh', {
method: 'POST',
credentials: 'include' // Send cookies
});
const data = await response.json();
accessToken = data.accessToken;
// Schedule next refresh before expiration
setTimeout(refreshAccessToken, 14 * 60 * 1000); // 14 minutes
}
// Initial token fetch after login
await refreshAccessToken();
Pro tip: Never store tokens in localStorage if your application handles sensitive data. While convenient, localStorage is vulnerable to XSS attacks that can steal tokens and impersonate users.
Security Best Practices
Implementing JWTs correctly requires attention to security at every level. Here are the essential practices you must follow:
1. Use Strong Secret Keys
Your secret keys should be cryptographically random and at least 256 bits (32 bytes) long:
// Generate a secure secret
const crypto = require('crypto');
const secret = crypto.randomBytes(32).toString('hex');
Never hardcode secrets in your source code. Use environment variables and keep them out of version control.
2. Set Appropriate Expiration Times
- Access tokens: 5-15 minutes for high-security apps, up to 1 hour for lower-risk applications
- Refresh tokens: 7-30 days, depending on your security requirements
- Remember me tokens: Up to 90 days with additional security measures
3. Validate All Claims
Always verify these claims on the server:
jwt.verify(token, secret, {
algorithms: ['HS256'], // Whitelist allowed algorithms
issuer: 'your-app-name',
audience: 'your-api',
clockTolerance: 30 // Allow 30 seconds clock skew
});
4. Implement Token Revocation
While JWTs are stateless, you need a revocation mechanism for critical scenarios:
- User logs out
- Password change
- Account compromise
- Permission changes
Maintain a blacklist of revoked tokens or use token versioning:
// Add token version to payload
{
"userId": "123",
"tokenVersion": 5
}
// Increment version on password change
user.tokenVersion += 1;
await user.save();
// Verify token version matches
if (payload.tokenVersion !== user.tokenVersion) {
throw new Error('Token revoked');
}
5. Use HTTPS Everywhere
JWTs must only be transmitted over HTTPS. Without encryption, tokens can be intercepted and used by attackers.
6. Implement Rate Limiting
Protect your authentication endpoints from 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('/auth/login', loginLimiter, loginHandler);
7. Sanitize Token Payloads
Never include sensitive information in JWT payloads:
- ❌ Passwords or password hashes
- ❌ Credit card numbers
- ❌ Social security numbers
- ❌ API keys or secrets
- ✅ User ID
- ✅ Email address
- ✅ Roles and permissions
- ✅ Non-sensitive metadata
8. Monitor and Log Authentication Events
Track authentication activities for security monitoring:
- Failed login attempts
- Token refresh requests
- Logout events
- Suspicious patterns (multiple IPs, unusual locations)
Common JWT Vulnerabilities
Understanding common JWT vulnerabilities helps you avoid them in your implementation. Here are the most critical security issues:
1. Algorithm Confusion Attack
Attackers change the algorithm from RS256 (asymmetric) to HS256 (symmetric) and sign the token with the public key.
Prevention:
// Always specify allowed algorithms
jwt.verify(token, secret, { algorithms: ['RS256'] });
2. None Algorithm Attack
Setting the algorithm to "none" bypasses signature verification entirely.
Prevention:
// Never allow 'none' algorithm
jwt.verify(token, secret, { algorithms: ['HS256', 'RS256'] });
3. Weak Secret Keys
Using weak or default secrets makes tokens vulnerable to brute force attacks.
Prevention: Use cryptographically random secrets of at least 256 bits.
4. Missing Expiration Validation
Failing to check the exp claim allows expired tokens to remain valid indefinitely.
Prevention:
// jsonwebtoken library checks exp by default
// But always verify it's present
if (!payload.exp) {
throw new Error('Token missing expiration');
}
5. Token Sidejacking
Attackers steal tokens through XSS, man-in-the-middle attacks, or insecure storage.
Prevention:
- Use HTTPS exclusively
- Implement Content Security Policy (CSP)
- Store tokens securely (HttpOnly cookies or memory)
- Use short expiration times
6. Cross-Site Request Forgery (CSRF)
When storing JWTs in cookies, CSRF attacks can trick users into making unwanted requests.
Prevention:
// Use SameSite cookie attribute
res.cookie('token', jwt, {
sameSite: 'strict',
httpOnly: true,
secure: true
});
// Or implement CSRF tokens for state-changing operations
7. JWT Injection
Attackers inject malicious data into JWT claims to exploit application logic.
Prevention:
- Validate and sanitize all claims
- Use type checking for expected claim values
- Implement strict claim validation schemas
Quick tip: Use our Security Headers Checker to verify your application has proper security headers configured to protect against common attacks.
Choosing the Right Algorithm
JWT supports multiple signing algorithms, each with different security properties and use cases. Choosing the right one is crucial for your application's security.
Symmetric Algorithms (HMAC)
HS256 (HMAC-SHA256), HS384, HS512
These algorithms use the same secret key for both signing and verification. They're fast and simple but require sharing the secret between all parties.
Best for:
- Single-server applications
- Microservices within a trusted network
- When performance is critical
Pros:
- Fast computation
- Simple implementation
- Smaller token size
Cons:
- Secret must be shared with all verifiers
- Anyone who can verify can also create tokens
- Key rotation is more complex
Asymmetric Algorithms (RSA/ECDSA)
RS256 (RSA-SHA256), RS384, RS512
RSA algorithms use a private key for signing and a public key for verification. The public key can be freely distributed.
Best for:
- Distributed systems
- Third-party API integrations
- When multiple services need to verify tokens
ES256 (ECDSA-SHA256), ES384, ES512
ECDSA provides similar security to RSA with smaller key sizes and faster operations.
Best for:
- Mobile applications (smaller tokens)
- IoT devices (less computational overhead)
- Modern applications prioritizing performance