Security model
Trust boundaries, non-negotiables, and what residual risk remains.
Security model
This is a zero-knowledge-oriented design: the server is untrusted for plaintext vault material. It is still trusted for availability, authorization of who may store wraps, and session integrity.
Trust boundaries
Device (browser) App server + plugins Database
┌──────────────────┐ ┌──────────────────────┐ ┌──────────┐
│ MEK in memory │ TLS │ Sessions, elevate │ │ Wrap │
│ PRF / password │ ──────► │ Blind wraps only │ ──────►│ ciphertext│
│ Recovery phrase │ │ Never raw MEK │ │ metadata │
└──────────────────┘ └──────────────────────┘ └──────────┘| Zone | Trusted for | Untrusted for |
|---|---|---|
| User device (unlocked) | Decrypting that user’s data | Other users |
| App JS (your frontend) | Same as the device | Server secrets |
| Better Auth server | Sessions, policy, wrap storage | Plaintext MEK |
| Database dump attacker | Reading wrap ciphertext | Unwrapping without client secrets |
Non-negotiables
- Elevate never unlocks MEK — no “if elevated, open vault” shortcuts.
- Server stores wraps + metadata only — never raw MEK, mnemonic, PRF output, or vault password.
- PRF
infobinds purpose + userId — cross-user mixups must fail. - Authenticator enforce filters fail closed when attestation is not explicitly available.
- Authorize elevate on the server from the session row (and MAC where used) — not from a client-supplied claim blob alone.
- Creating a vault is client/product code — the BA plugin only accepts already-wrapped material.
M6 — raw secret denylist
Request bodies to key-store routes are scanned for forbidden keys (mnemonic, rawMek, prfSecret, authSecret, …). Matching keys return 400 with a stable error code. Allowed recovery fields are carefully limited (for example public keys / verifiers, never the phrase).
Persistent key authority
zeroAccess({ secret?: string }) owns one server-only root. Omission captures Better Auth's resolved secret; an explicit stable Railman secret separates persistent-data lifecycle from session-secret rotation. The root derives non-extractable MAC and storage-ID families with a different purpose for each row or index class. Applications never receive or manage child authorities.
The MAC family protects persistent vault/E2E rows, recovery control rows, and the passkey counter watermark. It detects field tampering by a database attacker who lacks the root. It does not detect deletion or rollback of a complete, previously valid snapshot.
The storage-ID family covers low-entropy pseudonyms, rate/replay/challenge keys, and ephemeral recovery-bearer lookup. Long-lived E2E row reachability instead uses versioned public deterministic SHA-256 identifiers over canonical natural inputs. Those identifiers remain server-only and are included in row MACs.
This pre-1.0 format is a clean cutover. There are no legacy readers or migration path. Replace old security rows before serving the new format. Root loss or rotation invalidates current authenticated rows and passkey watermarks; no rotation CLI exists yet.
Residual risks (honest)
| Residual | Why it remains |
|---|---|
| Stolen unlocked browser tab | MEK is in memory by design while unlocked — use idle lock |
| Stolen session cookie | Attacker can store wraps as that user, not unwrap without L2 |
| Stolen session after elevate | Short TTL + session bind + optional rotation |
| Weak vault password | Client iterations floor helps; users can still pick poorly |
| Hosted demo D1 wipe | Lab data is disposable |
Verification
Maintainers run the hosted-equivalent core gate and security suites:
pnpm ci:core # build · lint · typecheck · unit:core · security · package check
pnpm test:security # tests/security matrix onlyThere is no separate security npm package — checks are tests.
Builders integrating the plugins should walk the integration checklist and the live Vault lab.
Pin the exact reviewed Better Auth release (1.6.28 today) — do not use a peer range for the core plugins.