Secure arbitrary JSON data in localStorage
using AES‑GCM‑256 envelope encryption.
- Password‑less mode: a non‑extractable KEK is generated and stored in IndexedDB (origin‑bound). Data is automatically available on this device.
- Master‑password mode: the KEK is derived from a user password via Argon2id (
argon2-browser
) with memory=64MiB, iterations=20, parallelism=1.
Built for browsers with the Web Crypto API. Bundled for ESM & CJS. Engine: Bun ≥ 1.2.22 (for builds & tests).
⚠️ This library secures data at rest inlocalStorage
. It cannot protect against a compromised page runtime (XSS, devtools, malicious extensions). Always follow secure coding & CSP best practices.
The demo includes most functionality provided by the API: https://mqxym.github.io/secure-local-storage/storage-example.html
bun add @mqxym/secure-local-storage
# or
npm i @mqxym/secure-local-storage
import secureLocalStorage from "@mqxym/secure-local-storage";
const sls = secureLocalStorage(); // init (device mode by default)
await sls.setData({ value1: 123, nested: { a: "b" } });
const data = await sls.getData<{ value1: number; nested: { a: string } }>();
console.log(data.value1); // 123
data.clear(); // wipe decrypted copy from memory
// getData() returns a write-protected object.
// To modify it before passing into setData(), create a deep copy
// (e.g., using JSON serialization).
await sls.setMasterPassword("correct horse battery staple"); // switch to master mode
sls.lock(); // remove keys from memory
await sls.unlock("correct horse battery staple"); // derive KEK and unlock
await sls.rotateMasterPassword("old pass", "new pass");
const exported = await sls.exportData("export-pass"); // JSON string
await sls.importData(exported, "export-pass"); // imports and rewraps to device mode by default
const sls = secureLocalStorage();
console.log(sls.DATA_VERSION) // returns current data version (2)
// Customized usage
const sls = secureLocalStorage({
storageKey: "tenant:123", // override localStorage key
idbConfig: {
dbName: "SLS_KEYS_TENANT123", // override IndexedDB database name
storeName: "keys", // override object store name
keyId: "deviceKek_v1" // override key record id
}
});
// Session / mode
await sls.unlock(masterPassword: string); // no-op when uninitialized / password-less mode
await sls.setMasterPassword(masterPassword: string);
await sls.removeMasterPassword();
await sls.rotateMasterPassword(oldMasterPassword: string, newMasterPassword: string); // switches to master password mode when in device key mode
sls.lock();
await sls.rotateKeys(); // password-less only
sls.isUsingMasterPassword() // true / false
// Data
const data = await sls.getData<T extends Record<string, unknown>>();
data.clear(); // securely wipes in-memory decrypted view
await sls.setData(setData: Record<string, unknown>);
// Import / export
const json = await sls.exportData(customExportPassword?: string); // JSON string
await sls.importData(json: string, exportOrMasterPassword?: string);
// Reset
await sls.clear(); // clears localStorage & IndexedDB and reinitializes in device mode
-
Envelope encryption:
-
Generate a DEK (
CryptoKey
, AES‑GCM‑256). DEK encrypts your JSON data. -
Wrap (encrypt) the DEK with a KEK.
- Device mode: KEK is a non‑extractable
CryptoKey
persisted in IndexedDB (origin‑scoped). - Master mode: KEK is derived via Argon2id (64MiB, 20 iters, p=1).
- Device mode: KEK is a non‑extractable
-
Persist to
localStorage
:{ "header": { "v": 2, "salt": "", "rounds": 1, "iv": "...", "wrappedKey": "..." }, "data": { "iv": "...", "ciphertext": "..." } }
-
-
Non‑extractable keys: KEK is non‑extractable. The DEK is generated extractable only to enable wrapping; when unwrapped for use it is kept non‑extractable. For rewrapping, it’s unwrapped into a short‑lived extractable key.
- All public APIs validate input types and session/mode invariants.
localStorage
quotas vary by browser (commonly ~5-10 MB). The library throws aStorageFullError
if writing exceeds quota.- Data must be JSON‑serializable.
- Requires Web Crypto (
SubtleCrypto
) and IndexedDB. If IndexedDB refuses to storeCryptoKey
(rare older engines), a memory fallback is used (data remains secure, but device mode becomes ephemeral between reloads). For CI/testing, we polyfill IndexedDB viafake-indexeddb
.
bun test
bun run build
- ESM output:
dist/esm/sls.browser.min.js
- CJS output:
dist/cjs/sls.browser.min.cjs
- Types:
dist/types/index.d.ts
- Clearing decrypted views calls a best‑effort memory wipe (overwriting object contents), but JS engines may keep copies; avoid holding long‑lived references to sensitive data.
- Use strong passwords in master mode. Argon2id settings: 20 iterations, 64 MiB memory, p=1, hashLen=32.
- Consider Content Security Policy (CSP), dependency pinning, and extension risk mitigation.
MIT