DEV Community

Robert Domestisck
Robert Domestisck

Posted on • Edited on

Stop Storing Secrets in localStorage: Patterns for a Secure Digital ID Wallet

TL;DR: localStorage is convenient, but it’s a glass box. If you’re building a digital ID wallet app (cards, IDs, passes), move secrets out of JS-readable storage. Use passkeys/WebAuthn for auth, httpOnly cookies or a BFF for sessions, and E2EE with non-extractable keys + IndexedDB for encrypted payloads. Sprinkle in CSP, Trusted Types, and a service worker for defense-in-depth.

Why localStorage is the wrong place for secrets

XSS exfiltration: Any script that runs in your origin can read localStorage. That means one missed output-escape, compromised NPM dependency, or misconfigured third-party and your tokens are gone.

Long-lived & persistent: Secrets survive tabs, reloads, and crashes. Great for convenience, terrible for incident response.

No flags: Unlike cookies, you can’t mark localStorage as HttpOnly, Secure, or SameSite.

Copyable at rest: DevTools, extensions, or shared machines can yank it.

What counts as a “secret”?
Access/refresh tokens, private keys, recovery seeds, raw PII, and unencrypted ID documents (images, PDFs, MRZ text). If losing it lets an attacker impersonate a user or decrypt their vault, it’s a secret.

Threat model for a Digital ID Wallet

Goal: store user cards/IDs encrypted at rest, unlock with the user’s device auth, and sync safely.

Trust boundaries: the server must never see plaintext vault data; the browser must not keep extractable keys in JS land.

Reality: XSS, supply-chain JS, malicious extensions, shoulder-surfing, stolen laptops.

So we need patterns that degrade gracefully even when XSS happens.

Pattern 1 — Authenticate with Passkeys (WebAuthn), not bearer tokens in JS

Use WebAuthn to create a device-bound credential. No tokens to hoard in localStorage.

// Register (create) a passkey
const publicKey: PublicKeyCredentialCreationOptions = {
challenge: await fetch('/webauthn/challenge').then(r => r.arrayBuffer()),
rp: { name: 'Your App', id: location.hostname },
user: {
id: new TextEncoder().encode(currentUserId),
name: currentUserEmail,
displayName: currentUserName,
},
pubKeyCredParams: [{ alg: -7, type: 'public-key' }, { alg: -257, type: 'public-key' }],
authenticatorSelection: { userVerification: 'required' },
};
const cred = await navigator.credentials.create({ publicKey });
await fetch('/webauthn/register', {
method: 'POST',
body: JSON.stringify(cred),
headers: { 'Content-Type': 'application/json' }
});

At sign-in, assert instead of exchanging passwords for bearer tokens:

const assertion = await navigator.credentials.get({
publicKey: {
challenge: await fetch('/webauthn/challenge').then(r => r.arrayBuffer()),
userVerification: 'required',
allowCredentials: [ /* your credential IDs */ ]
}
});
await fetch('/webauthn/verify', { method: 'POST', body: JSON.stringify(assertion) });

Session handling: Prefer an opaque, short-lived session issued server-side, returned as Set-Cookie with HttpOnly; Secure; SameSite=Strict. No token lands in JS.

Pattern 2 — Use a BFF (Backend-for-Frontend) instead of storing tokens client-side

If you must talk to third-party APIs, don’t hand access tokens to the browser. Let a thin BFF attach tokens server-side.

Browser -> BFF (/api/*) -> Upstream APIs
^ keeps only HttpOnly session cookie, no access tokens in JS

This removes the incentive to stash OAuth tokens in localStorage. When XSS fires, there’s nothing reusable to steal.

Bonus: If you need proof-of-possession, add DPoP at the BFF or mutual TLS upstream.

Pattern 3 — Encrypted vault in IndexedDB using non-extractable keys

Store encrypted cards/IDs in IndexedDB. The encryption key is a non-extractable CryptoKey wrapped by a passkey-protected key. Even if XSS lists your DB, it sees only ciphertext.

Step A: Create a content-encryption key (CEK)
// Non-extractable symmetric key
const cek = await crypto.subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
false, // not exportable
['encrypt', 'decrypt']
);

Step B: Wrap CEK with a user-bound key

Use a wrapping key tied to the device (via WebAuthn + platform keystore) or derive from a user secret combined with device signals. Here’s the Web Crypto wrap flow (assuming you obtained/imported wrappingKey securely):

const wrapped = await crypto.subtle.wrapKey(
'raw',
cek,
wrappingKey, // e.g., RSA-OAEP/WebAuthn-backed
{ name: 'RSA-OAEP' }
);
// Save wrapped in IndexedDB; unwrap it after user verification

Step C: Encrypt and store records
async function encryptBlob(cek: CryptoKey, data: Uint8Array) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
cek,
data
);
return { iv, ct: new Uint8Array(ct) };
}

Persist { iv, ct } in IndexedDB. Never keep plaintext or keys in localStorage.

Pattern 4 — Gate decryption behind User Verification (biometrics/PIN)

On each unlock, require userVerification: 'required' via WebAuthn (or OS keychain on desktop apps). This makes decryption events interactive and ties them to Face/Touch ID.

await navigator.credentials.get({
publicKey: {
challenge: await fetch('/unseal/challenge').then(r => r.arrayBuffer()),
userVerification: 'required',
allowCredentials: [ /* the credential used to wrap CEK */ ]
}
});
// server returns a one-time unwrap token or a wrapped CEK shard

Practical note: Cache a short-lived unwrapped CEK in memory only (not in storage). Clear it on tab close, inactivity, or lock.

Pattern 5 — Service Worker as a thin shield

A service worker can centralize fetches and enforce “no secrets in requests”:

// sw.js
self.addEventListener('fetch', (e) => {
const url = new URL(e.request.url);
// Block accidental leakage of internal headers/query params
if (url.searchParams.has('token')) {
e.respondWith(new Response('Forbidden', { status: 403 }));
return;
}
e.respondWith(fetch(e.request));
});

It also provides offline caching of encrypted payloads only (Cache Storage). Never cache decrypted responses.

Pattern 6 — Lock down the front end (CSP, Trusted Types, COOP/COEP)

CSP: default-src 'self'; script-src 'self' 'strict-dynamic' 'nonce-...'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'.

Trusted Types: eliminate DOM XSS sinks by requiring safe HTML builders.

Subresource Integrity: pin hashes for third-party JS (or better: self-host and sign).

COOP/COEP: isolate your browsing context to reduce cross-origin surprises and enable powerful APIs safely.

These won’t save you if you actively store secrets in JS storage, but they reduce XSS probability and blast radius.

Pattern 7 — Sync without trusting the server (E2EE)

For multi-device:

Generate CEK on Device A.

Wrap CEK using Device A’s wrapping key; upload only the wrapped blob.

On Device B, after user verification, re-wrap CEK for Device B and store locally.

Documents sync as ciphertext; the server never sees plaintext or unwrapped CEKs.

If you add multi-party access (e.g., family vault), use asymmetric envelope encryption: a unique data key per item, wrapped for each member’s public key.

Pattern 8 — Mobile & desktop: use platform keychains

If you ship native shells (Capacitor, React Native, desktop apps):

iOS: Keychain + Secure Enclave (non-exportable keys).

Android: Keystore with StrongBox where available.

Desktop: OS keyrings (Windows DPAPI, macOS Keychain, libsecret on Linux).

Store wrapped keys or small secrets there, never plaintext in sandboxed files.

What if you really need to remember something in the browser?

If you must persist some state:

Store opaque references or nonces, not secrets.

Use short expiry and bind them to device/credential where possible.

Rotate aggressively; treat it like a cache hint, not a keyring.

Migration playbook (away from localStorage)

Audit: find every read/write of localStorage, list the data classes (token, PII, UI prefs).

Classify: move only non-sensitive prefs (theme, layout) to localStorage; everything else migrates.

Session: swap JS bearer tokens for HttpOnly session cookies via a BFF.

Vault: introduce IndexedDB + non-extractable keys; write a one-time migration that decrypts old blobs and re-encrypts under the CEK.

App hardening: deploy CSP + Trusted Types; turn on SRI; review third-party scripts.

Kill switch: add a remote feature flag to force logout + key wipe for incident response.

Mini reference: Do / Don’t

Do

HttpOnly; Secure; SameSite cookies for sessions

WebAuthn passkeys for auth + user verification

Non-extractable keys + AES-GCM in IndexedDB

E2EE sync (server sees ciphertext only)

CSP, Trusted Types, SRI, SW hygiene

Don’t

Put tokens, private keys, or vault plaintext in localStorage

Cache decrypted data in service worker/Cache Storage

Log secrets to console/analytics

Rely on obfuscation or UI-level “locks” as real security

Closing thoughts

A digital ID wallet lives or dies by how you store secrets. localStorage was never meant to be a key vault. With passkeys for sign-in, server-side sessions (or a BFF), and an encrypted vault backed by non-extractable keys in IndexedDB, you raise the bar dramatically—without wrecking UX. Add platform keychains on mobile/desktop, and you’ve got a modern, user-friendly wallet that stays locked even when the browser misbehaves.

If you want a follow-up, I can post a small reference implementation: WebAuthn login + CEK wrapping + encrypted IndexedDB with React hooks and a service worker cache that never touches plaintext.

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.