You've implemented JWT authentication for your application, storing tokens in local storage because it seemed convenient. But now you're seeing alarming posts about XSS vulnerabilities and token theft, making you question if your user data is truly secure. You're looking for better options but feeling overwhelmed by conflicting advice and technical jargon.
Security shouldn't be this confusing, especially when it comes to protecting your users' sensitive information.
The Security Risks of Local Storage
When you store authentication tokens in local storage, you're essentially placing your users' session keys in a publicly accessible cabinet. Any JavaScript running on your page—including malicious scripts injected through XSS attacks—can access this storage and steal these tokens.
This isn't just theoretical. A successful XSS attack against your application could allow attackers to:
Capture JWT tokens and impersonate users
Access private user data
Perform actions on behalf of users
Maintain this access until the token expires (which could be hours or days)
You might be thinking, "But I sanitize all my inputs to prevent XSS!" While that's a good practice, complete XSS prevention is notoriously difficult. A single vulnerability anywhere in your application or in a third-party library you use could expose all your users' tokens.
Why httpOnly Cookies Are a Superior Alternative
There's a more secure approach: storing your JWT tokens in httpOnly cookies. These special cookies cannot be accessed by JavaScript, making them immune to theft through XSS attacks.
// Setting an httpOnly cookie on the server (Express.js example)
res.cookie('accessToken', token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 15 * 60 * 1000 // 15 minutes
});
When you use httpOnly cookies:
The browser automatically sends the cookie with every request to your domain
JavaScript cannot access the cookie's contents, protecting it from XSS attacks
Your authentication flow becomes more secure with minimal changes to your frontend code
Major tech companies including Google, Microsoft, and Netflix use httpOnly cookies for token storage—and for good reason.
Understanding JWT Structure and Purpose
Before diving deeper into implementation, let's review what JWTs actually are and why they're valuable.
A JWT consists of three parts separated by dots:
header.payload.signature
Header: Contains metadata about the token type and signing algorithm
{ "alg": "HS256", "typ": "JWT" }
Payload: Contains the claims (data) you want to transmit
{ "userId": "123", "name": "Jane Doe", "role": "admin", "exp": 1698508583 }
Signature: Verifies the token hasn't been tampered with
HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret )
JWTs are valuable because they're self-contained, allowing stateless authentication. The server doesn't need to query a database on every request to verify who the user is—it can simply validate the token's signature and read the claims.
Implementing JWT Authentication with httpOnly Cookies
Now that we understand the advantages of httpOnly cookies, let's implement a complete authentication flow. We'll use Express.js on the backend and React on the frontend, but the principles apply to any stack.
Server-Side Implementation
First, set up your Express server with the necessary middlewares:
const express = require('express');
const jwt = require('jsonwebtoken');
const cookieParser = require('cookie-parser');
const cors = require('cors');
const app = express();
// Enable parsing cookies and JSON body
app.use(cookieParser());
app.use(express.json());
// Configure CORS to allow credentials
app.use(cors({
origin: 'https://yourfrontend.com',
credentials: true
}));
Next, create login and authentication middleware endpoints:
// Login endpoint
app.post('/api/login', (req, res) => {
const { username, password } = req.body;
// Validate credentials (simplified for demo)
if (username === 'user' && password === 'pass') {
// Create JWT token
const accessToken = jwt.sign(
{ userId: '123', role: 'user' },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
// Create refresh token with longer expiry
const refreshToken = jwt.sign(
{ userId: '123' },
process.env.REFRESH_TOKEN_SECRET,
{ expiresIn: '7d' }
);
// Set cookies
res.cookie('accessToken', accessToken, {
httpOnly: true,
secure: true, // Use secure in production
sameSite: 'strict',
maxAge: 15 * 60 * 1000 // 15 minutes
});
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
path: '/api/refresh', // Only sent to refresh endpoint
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
});
// Send user data (but not the tokens)
res.json({ userId: '123', username: 'user' });
} else {
res.status(401).json({ message: 'Invalid credentials' });
}
});
// Middleware to authenticate requests
const authenticateToken = (req, res, next) => {
const token = req.cookies.accessToken;
if (!token) {
return res.status(401).json({ message: 'Authentication required' });
}
jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
if (err) {
return res.status(403).json({ message: 'Invalid or expired token' });
}
req.user = user;
next();
});
};
// Protected route example
app.get('/api/protected', authenticateToken, (req, res) => {
res.json({ message: 'This is protected data', user: req.user });
});
Token Refresh Implementation
To handle token expiration gracefully, implement a refresh token endpoint:
// Token refresh endpoint
app.post('/api/refresh', (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) {
return res.status(401).json({ message: 'Refresh token required' });
}
jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET, (err, user) => {
if (err) {
return res.status(403).json({ message: 'Invalid or expired refresh token' });
}
// Create new access token
const accessToken = jwt.sign(
{ userId: user.userId, role: 'user' },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
// Set new access token cookie
res.cookie('accessToken', accessToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 15 * 60 * 1000
});
res.json({ message: 'Token refreshed successfully' });
});
});
// Logout endpoint
app.post('/api/logout', (req, res) => {
// Clear both cookies
res.clearCookie('accessToken');
res.clearCookie('refreshToken', { path: '/api/refresh' });
res.json({ message: 'Logged out successfully' });
});
Client-Side Implementation
On the frontend, you'll need to configure your requests to include credentials:
// React example using fetch
const login = async (username, password) => {
try {
const response = await fetch('https://yourapi.com/api/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
credentials: 'include' // Important! This tells fetch to include cookies
});
if (!response.ok) {
throw new Error('Login failed');
}
const userData = await response.json();
// Store user data in state management (React Context, Redux, etc.)
return userData;
} catch (error) {
console.error('Login error:', error);
throw error;
}
};
For handling protected API requests:
// Utility function for API requests
const apiRequest = async (url, options = {}) => {
try {
const response = await fetch(url, {
...options,
credentials: 'include',
headers: {
...options.headers,
'Content-Type': 'application/json',
}
});
// If unauthorized, try to refresh the token
if (response.status === 401) {
const refreshResponse = await fetch('https://yourapi.com/api/refresh', {
method: 'POST',
credentials: 'include'
});
if (refreshResponse.ok) {
// Retry the original request after token refresh
return apiRequest(url, options);
} else {
// Redirect to login if refresh fails
window.location.href = '/login';
throw new Error('Session expired');
}
}
if (!response.ok) {
throw new Error('API request failed');
}
return response.json();
} catch (error) {
console.error('API error:', error);
throw error;
}
};
// Example usage
const fetchUserData = () => apiRequest('https://yourapi.com/api/protected');
Mitigating CSRF Vulnerabilities
While httpOnly cookies protect against XSS, they can be vulnerable to Cross-Site Request Forgery (CSRF) attacks. Here's how to protect against CSRF:
1. Use SameSite Cookie Attribute
The SameSite
attribute restricts when cookies are sent cross-origin:
res.cookie('accessToken', token, {
httpOnly: true,
secure: true,
sameSite: 'strict', // Only sent in same-site requests
maxAge: 15 * 60 * 1000
});
Setting SameSite=strict
ensures cookies are only sent for same-site requests, which significantly reduces CSRF risks.
2. Implement CSRF Tokens
For additional protection, especially in browsers that don't fully support SameSite, implement CSRF tokens:
// Generate and send CSRF token
app.get('/api/csrf-token', authenticateToken, (req, res) => {
const csrfToken = crypto.randomBytes(64).toString('hex');
// Store the token in a cookie that JS can read (not httpOnly)
res.cookie('X-CSRF-TOKEN', csrfToken, {
httpOnly: false,
secure: true,
sameSite: 'strict',
maxAge: 15 * 60 * 1000
});
res.json({ csrfToken });
});
// Middleware to verify CSRF token
const verifyCsrfToken = (req, res, next) => {
const csrfTokenFromCookie = req.cookies['X-CSRF-TOKEN'];
const csrfTokenFromHeader = req.headers['x-csrf-token'];
if (!csrfTokenFromCookie || !csrfTokenFromHeader || csrfTokenFromCookie !== csrfTokenFromHeader) {
return res.status(403).json({ message: 'CSRF token validation failed' });
}
next();
};
// Apply CSRF protection to mutation endpoints
app.post('/api/user/update', authenticateToken, verifyCsrfToken, (req, res) => {
// Handle user update
});
On the client side, include the CSRF token in your requests:
const updateUser = async (userData) => {
// Get CSRF token from cookie
const csrfToken = document.cookie
.split('; ')
.find(row => row.startsWith('X-CSRF-TOKEN='))
?.split('=')[1];
return apiRequest('https://yourapi.com/api/user/update', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken
},
body: JSON.stringify(userData)
});
};
Common Pitfalls and Best Practices
1. Handling Token Expiration
One common challenge is dealing with token expiration during active user sessions. We already implemented a refresh mechanism, but here's how to handle it even more efficiently using an async mutex to prevent multiple simultaneous refresh attempts:
import { Mutex } from 'async-mutex';
const tokenRefreshMutex = new Mutex();
const apiRequest = async (url, options = {}) => {
try {
const response = await fetch(url, {
...options,
credentials: 'include',
headers: {
...options.headers,
'Content-Type': 'application/json',
}
});
if (response.status === 401) {
// Acquire mutex to prevent multiple refresh requests
const release = await tokenRefreshMutex.acquire();
try {
// Check if another request already refreshed the token
const secondAttemptResponse = await fetch(url, {
...options,
credentials: 'include',
headers: {
...options.headers,
'Content-Type': 'application/json',
}
});
if (secondAttemptResponse.ok) {
return secondAttemptResponse.json();
}
// Try to refresh the token
const refreshResponse = await fetch('https://yourapi.com/api/refresh', {
method: 'POST',
credentials: 'include'
});
if (refreshResponse.ok) {
// Retry original request with new token
const finalResponse = await fetch(url, {
...options,
credentials: 'include',
headers: {
...options.headers,
'Content-Type': 'application/json',
}
});
if (finalResponse.ok) {
return finalResponse.json();
}
}
// Redirect to login if refresh fails
window.location.href = '/login';
throw new Error('Session expired');
} finally {
// Release mutex
release();
}
}
if (!response.ok) {
throw new Error(`API request failed: ${response.status}`);
}
return response.json();
} catch (error) {
console.error('API error:', error);
throw error;
}
};
2. Secure Logout Implementation
Ensure your logout functionality properly invalidates both cookies:
const logout = async () => {
try {
await fetch('https://yourapi.com/api/logout', {
method: 'POST',
credentials: 'include'
});
// Clear any client-side state
yourStateManager.clearUserData();
// Redirect to login page
window.location.href = '/login';
} catch (error) {
console.error('Logout error:', error);
}
};
3. CORS Configuration
Incorrect CORS settings are a common source of frustration when implementing cookie-based auth. Ensure your server has the proper configuration:
app.use(cors({
origin: ['https://yourfrontend.com', 'https://dev.yourfrontend.com'], // Can be an array
credentials: true, // Critical for cookies
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'X-CSRF-TOKEN']
}));
Conclusion
Storing JWTs in httpOnly cookies provides significantly better security than local storage by protecting tokens from JavaScript-based theft. While no security measure is perfect, httpOnly cookies combined with proper CSRF protection create a robust defense against common web vulnerabilities.
By implementing the patterns described in this guide, you can:
Protect user authentication tokens from XSS attacks
Maintain seamless user experiences with automatic token renewal
Properly handle session termination and token invalidation
Defense against CSRF attacks with token validation
Remember that security is a continuous process, not a one-time implementation. Stay updated on best practices, regularly audit your authentication flows, and be prepared to adapt as new security challenges emerge.
For additional resources on JWT security, check out:
By taking these steps to secure your JWT implementation, you're demonstrating a commitment to protecting your users' data and building trust in your application.