Sensitive apps (the hardening kit)
selfstore’s defaults are already private: no server, backups encrypted end to end, and the local IndexedDB cache is sealed at rest under a per-device key. For an app where a leak is genuinely serious - health notes, a legal file, anything intimate - you can make those guarantees non-optional and add one more, all through options. No fork, no separate build.
1. Refuse plaintext: requireEncryption
Turn encryption from a default into an invariant. The store then refuses every path that could leave a readable copy anywhere it does not control:
const store = await selfstore('clinic-notes', { requireEncryption: true });
- Connecting a destination without a password (or a group) throws
ENCRYPTION_REQUIRED- a home may only receive ciphertext. unprotect()/ turning encryption off is refused.- Exporting a plaintext backup is refused.
The on-device working cache is unchanged by this flag (it is governed by the
browser profile, and by cacheLock below); requireEncryption is about the
copy that travels.
2. Demand a strong password: passwordPolicy
A password floor, enforced at the store - so no screen in your app can forget to check it:
const store = await selfstore('clinic-notes', {
requireEncryption: true,
passwordPolicy: {
minLength: 12,
requireUppercase: true,
requireDigit: true,
requireSymbol: true,
},
});
A weaker password is rejected with WEAK_PASSWORD at protect() and at every
key add. For a live UI hint as the user types, preview the same rules without
mutating anything:
import { checkPasswordPolicy } from 'selfstore';
const { ok, unmet } = checkPasswordPolicy(candidate, policy);
// unmet: ('minLength' | 'lowercase' | 'uppercase' | 'digit' | 'symbol')[]
It is unicode-aware (an accented letter counts as a letter, an emoji as one character). A policy bounds the worst case; it cannot make a compliant password unguessable. Argon2id still does the slow part.
3. Lock the local cache: cacheLock
At-rest cache encryption is always on, but its key sits next to the data, so it falls to a copy of the whole browser profile. For the top tier, seal the cache under a key held in memory only - a password, or a key your app already has (a passkey PRF result). A copied profile then carries no usable key.
const store = await selfstore('clinic-notes', {
requireEncryption: true,
passwordPolicy,
// Called once at boot to unlock the cache; re-called on a wrong secret.
cacheLock: async ({ failed }) => promptForPassphrase({ failed }),
});
There is no way around one unlock per session: a secret that could be derived without asking could be derived by whoever copied the profile too. Branch it on your app’s existing login and the UX does not change - you already had an unlock moment. See cacheLock in the security guide for the honest boundary (it beats a profile copy, not code running in your origin).
4. Offer only what you allow
The connect widget shows exactly the destinations you enable. A clinic app might allow an S3 bucket and WebDAV, and nothing else:
<selfstore-connect id="c"></selfstore-connect>
<script type="module">
document.getElementById('c').targets = { webdav: true, s3: true };
</script>
Sharing is opt-in the strongest way there is: if you never mount the
<selfstore-share> / <selfstore-join> widgets and never import their entry,
that code is absent from your bundle, not merely hidden. A store that must
never be shared simply never gains the ability.
5. The honest ceiling
None of this changes the one limit no browser app escapes: your origin. Code running in your page - through XSS, a compromised dependency, a bad CDN - reads the decrypted data and the in-memory password, whatever the cache lock is set to. The hardening kit raises the floor a long way; it does not move that ceiling. Mitigate it the only way that works:
- A strict Content-Security-Policy, no third-party scripts.
- Subresource Integrity and, ideally, a reproducible build.
- Ship only the entries you use (see step 4) to keep the attack surface small.
The full analysis, including what each layer does and does not defeat, is the security guide and the threat model.
The whole thing, together
import { selfstore, checkPasswordPolicy } from 'selfstore';
const policy = { minLength: 12, requireUppercase: true, requireDigit: true, requireSymbol: true };
const store = await selfstore('clinic-notes', {
requireEncryption: true, // ciphertext-only leaves the device
passwordPolicy: policy, // a strong password is enforced
cacheLock: async ({ failed }) => promptForPassphrase({ failed }), // profile-copy-proof cache
});
// Encryption + a policy-checked password, then a bucket the clinic controls.
await store.protect(await promptForStrongPassword(policy));
await store.connectS3(clinicBucketConfig);