The Problem with localStorage
Most tutorials store JWT tokens in localStorage. It's simple: after login, you save the token there and attach it to every request via Authorization: Bearer. But this practice has a fatal flaw: any JavaScript running on your page can read localStorage. That includes third-party scripts, browser extensions, and, critically, an attacker's code injected via XSS.
Once an attacker reads the token, they can use it from anywhere for its entire lifetime (often 7 days or more). Since JWTs are stateless, you can't revoke them. The attacker becomes you.
Memory Storage: Better but Not Enough
Keeping the token in a JavaScript variable (module scope) makes it harder to grab, but not impossible. The attacker's code runs in the same context and can intercept the token when it's used, e.g., by monkey-patching fetch. Worse, the token disappears on page refresh, forcing you to use a refresh token. But where do you store that? Back in localStorage? That brings us full circle.
The httpOnly Cookie Solution
An httpOnly cookie is set by the server with the HttpOnly flag. JavaScript cannot read it at all. The browser automatically attaches it to requests to the same domain. This prevents token exfiltration during XSS.
Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Lax; Path=/
Your frontend code becomes simpler—no token management, just credentials: 'include':
async function login(email, password) {
await fetch('/api/login', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
}
async function fetchProfile() {
const res = await fetch('/api/me', { credentials: 'include' });
return res.json();
}
CSRF: The Trade-off
Cookies are automatically sent with requests, making them vulnerable to Cross-Site Request Forgery (CSRF). A malicious site can submit a form to your API and the browser will include the cookie. However, CSRF is a solved problem.
Defense 1: CSRF Tokens
The server sets a CSRF token in a readable cookie (not HttpOnly). Your frontend reads it and sends it as a custom header (e.g., X-CSRF-Token). The server verifies the header matches the cookie. Since the attacker can't read the cookie (same-origin policy) and can't set custom headers from a form, the attack fails.
function getCsrfToken() {
return document.cookie
.split('; ')
.find(c => c.startsWith('csrf_token='))
?.split('=')[1];
}
async function post(path, body) {
return fetch(path, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCsrfToken() ?? '',
},
body: JSON.stringify(body),
});
}
Defense 2: SameSite Cookies
Setting SameSite=Lax (or Strict) tells the browser not to send the cookie on cross-site requests. This blocks most CSRF automatically. However, Lax still sends cookies on top-level GET navigations (e.g., clicking a link), so it shouldn't be your only defense.
The Optimal Pattern
For modern single-page applications, the recommended approach is:
- Use httpOnly cookies for the session token.
- Implement CSRF protection via a double-submit cookie pattern (CSRF token in a readable cookie echoed back as a header).
- Set
SameSite=Laxas a second layer. - Keep refresh tokens server-side only (not exposed to the client).
This pattern minimizes the blast radius of XSS: the attacker can only make requests while the session is active, from the victim's browser, under your monitoring. They cannot exfiltrate the token and use it later.
Conclusion
Stop storing JWTs in localStorage. Use httpOnly cookies with CSRF protection. It's more secure and simplifies your frontend code. Your users and your security team will thank you.

