Zero-knowledge encryption
How sensitive fields are encrypted at rest with AES-256-GCM and a per-user data key. The deployment master key sits outside the database — so a compromise yields ciphertext, not net worths.
Zero-knowledge encryption
You're trusting Millefold with the same kind of data a bank holds about you. Your net worth, your cost basis, your debts. We took the position that *we* shouldn't be able to read it either. Here's how that works in concrete terms.
The threat model
Two scenarios you actually care about:
1. Database compromise. Someone steals the database. Without keys, they get useless ciphertext. 2. Subpoena or insider risk. Someone with legal or administrative access tries to read your records. Same outcome — ciphertext.
Both are real. Both are addressed by the same design.
The crypto
Every sensitive field on assets, liabilities, snapshots, wishlist items, and pies is encrypted on the Cloudflare Worker: decrypted to render, re-encrypted before write. The encryption keys never sit in the database and never touch the browser.
- Algorithm: AES-256-GCM via the Web Crypto API. Authenticated encryption — any tampering with the ciphertext blows the GCM tag and decryption fails closed.
- Key model: one master key per Millefold deployment, stored as a Cloudflare Workers secret (not in the database, not in the codebase, not in CI logs). A per-user data key is derived on every operation via HKDF, using the user's id as salt. One user's plaintext leak doesn't weaken any other user.
- Versioned ciphertext: every blob is
[version(1) | iv(12) | ct||tag(...)]base64-encoded. The version byte lets us rotate keys without re-encrypting the entire dataset up front.
What's encrypted
| Table | Fields |
|---|---|
| assets | name, current_value, cost_basis, annual_income, notes |
| liabilities | name, principal, monthly_payment, apr, notes |
| snapshots | total_assets, total_liabilities, net_worth |
| wishlist_items | name, target_value, notes |
| pies | name, description |
What's not — and why
- Categories, currencies, IDs, timestamps, ownership refs. We need these in plaintext so row-level security policies, joins, and
order bywork. They are the *shape* of your data, not the values. - Public profile fields (slug, display_name, bio, level, avatar). These are public by design when you opt in — see privacy by design for the policy.
- Web Push subscription tokens. These are the credential to actually send you a push notification. We need them in plaintext to deliver.
- Email. Supabase Auth owns the canonical record. A redundant encrypted copy adds no real-world security.
The subpoena math
If we were served a subpoena tomorrow for your account, here is what could be produced:
- Your email address (held by Supabase Auth).
- The encrypted blobs from the tables above, which can't be decrypted without the deployment master key.
- The deployment master key is held in a Cloudflare Workers secret. It is not in the database, not on any local disk, and not in CI.
That is the meaningful difference between "encrypted at rest" as a marketing line and what we actually built. Most products encrypt the database disk and then store plaintext rows on top of it. Disk encryption keeps a thief out. It does not keep the operator out.
Key rotation
The version byte in every ciphertext blob is how we'd handle a key rotation: write all new data under v=2, lazy-migrate v=1 blobs on read, and re-key in the background. No downtime, no big-bang re-encrypt.
Open questions we're still working on
- Client-held keys. Today the master key sits with the operator. A future mode where the user holds the decryption key (and a forgotten password = forgotten data) is on the roadmap.
- Searchable encryption. Filtering encrypted fields server-side is hard. For now, search-by-name happens client-side after decryption.
Read more
- Privacy by design — the policy layer: what data we collect, what's public, and your controls.
- Full privacy policy — the legal version.