Encryption at rest
Sensitive financial fields are encrypted at rest with AES-256-GCM and per-user keys derived from a master key. The operator holds that key and the server decrypts data to show it — encryption at rest, not zero-knowledge.
Encryption at rest
You're trusting Millefold with the same kind of data a bank holds about you: your net worth, your cost basis, your debts. Here's exactly how it's stored — including what encryption does *not* protect against.
What it protects against
- A leaked database. Someone who gets a copy of the database or a backup, but not the key, gets ciphertext for the encrypted fields listed below.
- Database-only access. Access to the database alone isn't enough to read those fields — the key is kept in a separate system.
What it doesn't protect against
Millefold is not zero-knowledge. The master key is held by the operator, and the server decrypts your data every time it shows you a page and when it sends the optional weekly summary email. Whoever controls both the running application and its key — including the operator — can read your data. Encryption at rest is a safeguard against leaks, not a promise that the operator can't see your numbers.
The crypto
Sensitive fields are encrypted on the server (Cloudflare Workers) before they're written and decrypted when they're read. The key is never stored in the database and never sent to your browser.
- Algorithm: AES-256-GCM via the Web Crypto API. Authenticated encryption — any tampering with the ciphertext makes decryption fail.
- Key model: one master key per deployment, stored as a Cloudflare Workers secret (not in the database, not in the codebase). A per-user key is derived from it with HKDF, using your user id as salt, so one user's key doesn't unlock anyone else's data.
- Versioned ciphertext: every blob is
[version(1) | iv(12) | ct||tag(...)]base64-encoded. The version byte lets keys be rotated without re-encrypting the entire dataset up front.
What's encrypted
- Assets — name, current value, cost basis, annual income, notes
- Liabilities — name, principal, monthly payment, APR, notes
- Snapshots — total assets, total liabilities, net worth, per-category breakdown
- Wishlist items — name, target value, notes
- Pies — name, description
What's not — and why
- Categories, currencies, IDs, timestamps, fund expense ratios and ownership references. These stay in plaintext so row-level security policies, joins, and sorting work.
- Imported transactions. Broker CSV imports create transaction rows — type, amount, ticker, share count, unit price, date — that are stored unencrypted. Row-level security limits them to your account.
- Profile fields — email, display name, slug, bio, avatar, country, default currency, discipline score and level, annual contribution target. Public profile fields are public by design when you opt in — see privacy by design.
- Web Push subscription tokens. Needed in plaintext to deliver notifications.
- Sign-in data. Supabase Auth holds your email and sign-in records.
Key rotation
The version byte in every ciphertext blob is how a key rotation would work: write all new data under v=2, re-encrypt v=1 blobs lazily on read, and re-key the rest in the background. No downtime, no big-bang re-encrypt.
Not built
- Client-held keys. Today the key is held by the operator. There is no mode where only you hold the key.
- Searching encrypted fields. Encrypted fields can't be filtered or sorted inside the database, so that happens on the server after decryption.
Read more
- Privacy by design — the policy layer: what's private, what's public, and your controls.
- Full privacy policy — the legal version.