Building a Secure JWT Authentication System with React and Express
Introduction
Did you know that a single line of code could leave your application wide open to malicious attacks? Authentication forms the foundation of modern web applications. Yet, implementing it securely demands careful thought about where tokens are stored, how they're automatically refreshed, and the proper way to handle requests. This comprehensive guide walks you through building a production-ready JWT authentication system that tackles common security challenges while delivering a smooth user experience.
Many tutorials make the mistake of storing JWT tokens in localStorage, which leaves them vulnerable to XSS attacks. When cross-site scripting vulnerabilities exist, attackers can steal tokens and impersonate users. We need a better approach—one that treats security as a fundamental requirement, not an afterthought.
The Problem with Traditional Approaches
LocalStorage is certainly convenient to work with—just a simple localStorage.setItem('token', 'mytoken') and you're done. It offers persistence and plenty of storage space (around 5MB). But this ease of use comes with a serious security trade-off: localStorage provides absolutely no defense against Cross-Site Scripting (XSS) attacks.
Understanding the Real Threat
Picture this scenario: someone manages to inject a malicious script into your website. This could happen through a compromised third-party library, an insecure input field, or any number of attack vectors. Once that script is running, it can easily access everything stored in localStorage, package it up, and transmit it to an attacker's server. Just like that, your users' tokens—and their accounts—are compromised.
Some developers believe they can solve this by encrypting the JWT before placing it in localStorage. Unfortunately, this doesn't address the core issue. Since the decryption key would also need to be stored somewhere accessible to your JavaScript, it provides no real protection against XSS attacks.
❌ CRITICAL SECURITY FLAW
localStorage.setItem('token', token); // Accessible to ANY script!
localStorage.setItem('encryptedToken', encrypt(token)); // Still vulnerable!
The fundamental problem: tokens stored in localStorage are like leaving your house keys under the doormat.
Our Solution: A Hybrid Token Strategy
We'll implement a dual-token system similar to what major companies like Google and Meta use—an approach that balances security with user experience:
- Access Token - Short-lived (15 minutes), kept in React Context memory
- Refresh Token - Long-lived (7 days), secured in HTTP-only cookies
This strategy delivers:
- ✅ Strong protection against XSS attacks (refresh token stays out of JavaScript's reach)
- ✅ Defense against CSRF through SameSite cookie attributes
- ✅ Automatic token refresh before expiration
- ✅ Request queueing during token refresh
- ✅ Seamless session restoration on page refresh
Why HTTP-Only Cookies Are Your Security Foundation
While cookies have sometimes been viewed skeptically, they represent battle-tested security when used correctly. For sensitive JWT storage, HTTP-only cookies are the gold standard.
The Power of HTTP-Only and SameSite
The HTTP-Only Flag: When your server responds with a Set-Cookie header that includes the httpOnly flag, the browser treats that cookie like a secure vault. Neither your JavaScript code nor any malicious script can access it. This creates a powerful defense layer against XSS attacks—even if an attacker manages to inject code, they can't steal the token.
The SameSite Attribute: Concerned about Cross-Site Request Forgery (CSRF)? The SameSite attribute revolutionized cookie security. By setting it to Lax or Strict, you tell the browser to only include the cookie when requests come from your own domain. This effectively blocks the most common CSRF attack patterns.
✅ SECURE APPROACH
res.cookie('refreshToken', token, {
httpOnly: true, // JavaScript cannot access it
secure: true, // HTTPS only
sameSite: 'strict' // CSRF protection
});
The Hybrid Pattern Explained
For enterprise-grade security combined with great user experience, the hybrid approach is what professionals use. Here's how it works:
Short-lived Access Token in Memory: Store your access token as a JavaScript variable that only exists in memory—when the browser tab closes, it's gone. In React applications, Context provides the perfect mechanism for managing this ephemeral token. It's available to any component that needs it (for checking roles, displaying user info, etc.), but since it only lives in memory, it poses minimal XSS risk.
Long-lived Refresh Token in Secure Cookies: Your refresh token lives in an HTTP-only cookie, completely inaccessible to JavaScript. When your React app loads, it can request the server to verify this secure cookie. If valid, the server issues a fresh short-lived access token back to your React Context, seamlessly restoring the user's session.
This pattern provides fast performance while maintaining tight security.
Architecture Overview
Backend Implementation
Setting Up the Express Server
Let's build a secure Express backend where the server handles token security responsibility.
const express = require('express');
const jwt = require('jsonwebtoken');
const cookieParser = require('cookie-parser');
const cors = require('cors');
const app = express();
// Configuration
const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET;
const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET;
const ACCESS_TOKEN_EXPIRY = '15m'; // Short-lived
const REFRESH_TOKEN_EXPIRY = '7d'; // Long-lived
// Middleware
app.use(express.json());
app.use(cookieParser());
app.use(cors({
origin: 'http://localhost:3000',
credentials: true // Critical for cookies!
}));
[!IMPORTANT] In production, always use environment variables for secrets. Hardcoding API keys or secrets is a critical security violation!
Token Generation Functions
function generateAccessToken(user) {
return jwt.sign(
{ id: user.id, username: user.username },
ACCESS_TOKEN_SECRET,
{ expiresIn: ACCESS_TOKEN_EXPIRY }
);
}
function generateRefreshToken(user) {
return jwt.sign(
{ id: user.id },
REFRESH_TOKEN_SECRET,
{ expiresIn: REFRESH_TOKEN_EXPIRY }
);
}
Login Endpoint
The login endpoint validates user credentials and establishes both tokens. Notice how the refresh token never appears in the response body—instead, it's transmitted as a secure cookie:
app.post('/api/auth/login', async (req, res) => {
const { username, password } = req.body;
// Find user (in production, query your database)
const user = users.find(u => u.username === username);
if (!user || password !== user.password) {
return res.status(401).json({ message: 'Invalid credentials' });
}
const accessToken = generateAccessToken(user);
const refreshToken = generateRefreshToken(user);
// Set refresh token as HTTP-only cookie
res.cookie('refreshToken', refreshToken, {
httpOnly: true, // Cannot be accessed via JavaScript
secure: process.env.NODE_ENV === 'production', // HTTPS only in prod
sameSite: 'strict', // CSRF protection
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
});
// Send access token in response body
res.json({
accessToken,
user: { id: user.id, username: user.username }
});
});
[!TIP] Here's the beauty of cookies: the refresh token automatically accompanies every request to the same domain. Your frontend doesn't need to manually attach it—the browser handles the secure attachment and protection automatically!
Token Refresh Endpoint
This endpoint trades a valid refresh token for a new access token:
app.post('/api/auth/refresh', (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) {
return res.status(401).json({ message: 'Refresh token not found' });
}
jwt.verify(refreshToken, REFRESH_TOKEN_SECRET, (err, decoded) => {
if (err) {
return res.status(403).json({ message: 'Invalid refresh token' });
}
const user = users.find(u => u.id === decoded.id);
if (!user) {
return res.status(403).json({ message: 'User not found' });
}
// Generate new access token
const accessToken = generateAccessToken(user);
res.json({ accessToken });
});
});
Protected Route Middleware
function verifyToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ message: 'Access token required' });
}
jwt.verify(token, ACCESS_TOKEN_SECRET, (err, decoded) => {
if (err) {
return res.status(403).json({ message: 'Invalid or expired token' });
}
req.user = decoded;
next();
});
}
// Example protected route
app.get('/api/protected', verifyToken, (req, res) => {
res.json({
message: 'This is protected data',
user: req.user
});
});
Logout Endpoint
app.post('/api/auth/logout', (req, res) => {
// Clear the refresh token cookie
res.clearCookie('refreshToken', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict'
});
res.json({ message: 'Logged out successfully' });
});
Frontend Implementation
Creating the Axios Instance
We'll create two separate Axios instances with distinct responsibilities:
- authAPI - Handles authentication endpoints (no interceptor)
- api - Manages protected endpoints (with automatic token handling and refresh)
import axios from 'axios';
const BASE_URL = 'http://localhost:5000/api';
// Auth endpoints (no interceptor needed)
export const authAPI = axios.create({
baseURL: BASE_URL,
withCredentials: true, // Send cookies with requests
headers: { 'Content-Type': 'application/json' }
});
// Protected endpoints (with interceptor)
export const api = axios.create({
baseURL: BASE_URL,
withCredentials: true,
headers: { 'Content-Type': 'application/json' }
});
Request Interceptor: Automatic Token Attachment
Rather than manually adding the Authorization header to each request, we use an interceptor. This approach keeps your code clean and ensures consistency:
// Store auth functions (set later by AuthContext)
let getAccessTokenFn = null;
let refreshAccessTokenFn = null;
let logoutFn = null;
export const setupInterceptors = (getAccessToken, refreshAccessToken, logout) => {
getAccessTokenFn = getAccessToken;
refreshAccessTokenFn = refreshAccessToken;
logoutFn = logout;
};
// Request interceptor - automatically attach token
api.interceptors.request.use(
(config) => {
if (getAccessTokenFn) {
const token = getAccessTokenFn();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
console.log('🔑 Token attached to:', config.url);
}
}
return config;
},
(error) => Promise.reject(error)
);
Response Interceptor: Automatic Token Refresh
Here's where the real power lies. When a request fails with a 401 (unauthorized) response, we automatically refresh the token and retry the request—completely transparent to the user:
let isRefreshing = false;
let failedQueue = [];
const processQueue = (error, token = null) => {
failedQueue.forEach(prom => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
failedQueue = [];
};
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// Handle 401 errors
if (error.response?.status === 401 && !originalRequest._retry) {
// If refresh is already in progress, queue this request
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
.then(token => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return api(originalRequest);
})
.catch(err => Promise.reject(err));
}
originalRequest._retry = true;
isRefreshing = true;
try {
// Refresh the access token
const newAccessToken = await refreshAccessTokenFn();
// Update the failed request with new token
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
// Process all queued requests
processQueue(null, newAccessToken);
isRefreshing = false;
// Retry the original request
return api(originalRequest);
} catch (refreshError) {
// If refresh fails, logout user
processQueue(refreshError, null);
isRefreshing = false;
if (logoutFn) {
logoutFn();
}
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
[!NOTE] The Importance of Request Queueing: Without queueing, if 5 requests fail at the same time, you'd trigger 5 separate refresh attempts. Our implementation solves this—only one refresh call happens, and all pending requests wait for and use the new token. This prevents race conditions and unnecessary server load.
React Context for Authentication State
React Context is perfect for managing the short-lived access token. We store it as a simple JavaScript variable in memory that vanishes when the browser tab closes. This gives us fast access for UI operations (checking user roles, displaying user info) while minimizing XSS exposure:
import React, { createContext, useState, useContext, useEffect, useRef, useCallback } from 'react';
import { authAPI } from '../api/axios';
const AuthContext = createContext(null);
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [accessToken, setAccessToken] = useState(null);
const [loading, setLoading] = useState(true);
const refreshTimerRef = useRef(null);
// Login function
const login = async (username, password) => {
try {
const response = await authAPI.post('/auth/login', {
username,
password
});
const { accessToken: newToken, user: userData } = response.data;
setAccessToken(newToken);
setUser(userData);
console.log('✅ Login successful');
return { success: true };
} catch (error) {
return {
success: false,
message: error.response?.data?.message || 'Login failed'
};
}
};
// Refresh access token
const refreshAccessToken = useCallback(async () => {
try {
const response = await authAPI.post('/auth/refresh');
const { accessToken: newToken } = response.data;
setAccessToken(newToken);
console.log('🔄 Access token refreshed');
return newToken;
} catch (error) {
console.error('❌ Token refresh failed:', error);
throw error;
}
}, []);
// Logout function
const logout = useCallback(async () => {
try {
await authAPI.post('/auth/logout');
} catch (error) {
console.error('❌ Logout error:', error);
} finally {
setAccessToken(null);
setUser(null);
if (refreshTimerRef.current) {
clearInterval(refreshTimerRef.current);
}
}
}, []);
// Auto-refresh token every 5 minutes
useEffect(() => {
if (accessToken) {
startRefreshTimer();
}
return () => {
if (refreshTimerRef.current) {
clearInterval(refreshTimerRef.current);
}
};
}, [accessToken]);
const startRefreshTimer = () => {
if (refreshTimerRef.current) {
clearInterval(refreshTimerRef.current);
}
console.log('⏱️ Started auto-refresh timer (5 minutes)');
refreshTimerRef.current = setInterval(async () => {
try {
await refreshAccessToken();
console.log('✅ Token auto-refreshed successfully');
} catch (error) {
console.error('❌ Auto-refresh failed, logging out');
logout();
}
}, 5 * 60 * 1000); // 5 minutes
};
// Restore session on mount
useEffect(() => {
const restoreSession = async () => {
try {
// Try to refresh token (checks if refresh token cookie exists)
const response = await authAPI.post('/auth/refresh');
const { accessToken: newToken } = response.data;
if (newToken) {
// Fetch user details
const userResponse = await authAPI.get('/user/me', {
headers: { Authorization: `Bearer ${newToken}` }
});
setAccessToken(newToken);
setUser(userResponse.data);
console.log('✅ Session restored');
}
} catch (error) {
console.log('ℹ️ No active session found');
} finally {
setLoading(false);
}
};
restoreSession();
}, []);
return (
<AuthContext.Provider value={{
user,
accessToken,
loading,
login,
logout,
refreshAccessToken
}}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
};
Connecting Everything in App.js
import React, { useEffect, useRef } from 'react';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import { setupInterceptors } from './api/axios';
function AppContent() {
const { user, accessToken, loading, refreshAccessToken, logout } = useAuth();
const interceptorsSetup = useRef(false);
// Setup Axios interceptors once
useEffect(() => {
if (!interceptorsSetup.current) {
setupInterceptors(
() => accessToken,
refreshAccessToken,
logout
);
interceptorsSetup.current = true;
}
}, [accessToken, refreshAccessToken, logout]);
if (loading) {
return <div>Loading...</div>;
}
return (
<div>
{user ? <Dashboard /> : <Login />}
</div>
);
}
function App() {
return (
<AuthProvider>
<AppContent />
</AuthProvider>
);
}
export default App;
Security Best Practices
1. Never Store Refresh Tokens in localStorage
❌ BAD: localStorage.setItem('refreshToken', token);
✅ GOOD: HTTP-only cookie (not accessible via JavaScript)
2. Use Short-lived Access Tokens
const ACCESS_TOKEN_EXPIRY = '15m'; // 15 minutes
Short expiration times limit the window of opportunity if an access token gets compromised.
3. Enable CORS Properly
app.use(cors({
origin: 'http://localhost:3000',
credentials: true // Required for cookies
}));
4. Use HTTPS in Production
res.cookie('refreshToken', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production', // HTTPS only
sameSite: 'strict'
});
5. Implement Request Queueing
Without proper queueing, multiple simultaneous failed requests can trigger multiple refresh attempts, creating race conditions and degraded performance. Our implementation handles this elegantly.
Post a Comment