← All posts

What we can and cannot read: TruMint’s encryption design

1 August 2026 · 9 min read · TruMint


Every finance app says “bank-level security.” It usually means TLS in transit and disk encryption at rest. Both are worth having and both are beside the point, because in that model the company can still read everything: every engineer with production access, every backup, every log pipeline, every subpoena.

We wanted a stronger claim. The first version of this post said we had one — that TruMint was built so we cannot read your data. Then we spent a day attacking that sentence with the same seriousness we would attack someone else’s, and found it was true of our database and false of our system. This is the rewrite. It tells you exactly which bytes are unreadable to us, which are not, and why.

The short version
Your financial values — amounts, balances, merchant and counterparty names, holdings, tax figures, assistant conversations — are encrypted with a key that only an attested production enclave can unwrap. Our database, our backups and our logs hold ciphertext. What stays readable is a deliberately narrow set of structural metadata: identifiers, dates, and a handful of coarse enums the database must filter on. Part 3 covers where this stops.

Who we are defending against

A privacy claim without a threat model is marketing. Ours is a short list, and the honest answer differs per row.

AdversaryGets what
Database breachCiphertext for every value field. Structural metadata in the clear.
Stolen backup or exportSame as above — backups contain the same encrypted documents.
Cloud provider / infrastructureCiphertext. The key is held by a KMS that only answers an attested workload.
Our own administratorCannot unwrap the key. Can still sign in as a user through an account takeover — but one that is now detected and emailed to you. Part 3.
Legal compulsionWhat we can hand over is what we can read, which is the same list as above.
What each adversary obtains. The fourth row is the uncomfortable one and we treat it separately rather than hiding it.

Per-user envelope encryption

Every user gets their own data encryption key (DEK), generated as 32 random bytes the first time we need it. That DEK never leaves the server in plaintext and is never stored in plaintext. It is wrapped by a master key (KEK) that lives in a hardware-backed key management service in London, and the wrapped form is stored on your own user document.

   your field value
        │
        │  gzip  ──►  AES-256-GCM  ──►  { __enc:1, iv, ct, tag }  ──►  Firestore
        │                  ▲
        │                  │ per-user DEK (32 random bytes, never persisted raw)
        │                  │
        │            ┌─────┴───────────────┐
        │            │  wrapped DEK        │  stored on users/{uid}.dekWrapped
        │            │  (ciphertext)       │
        │            └─────┬───────────────┘
        │                  │ unwrap
        │                  ▼
        │            Cloud KMS  ·  key never leaves the HSM boundary
        │            europe-west2 (London)
        ▼
   AAD binds the ciphertext to WHERE it lives:
        userId │ collection │ docId │ field │ v1
Envelope encryption. A breach of the database yields wrapped DEKs and ciphertext; unwrapping requires the KEK, and the KEK will only answer the enclave (Part 2).

Why the AAD matters

Each encrypted value carries additional authenticated data derived from its own address: the owner, the collection, the document id, and the field name. AES-GCM verifies that string on decrypt. Move a ciphertext blob from one document to another — or from one user to another — and decryption fails rather than silently succeeding. It is a cheap defence against an attacker who can write to the database but not read it.

Decryption fails closed

If a value cannot be decrypted, the read throws. There is no code path where “couldn’t decrypt” degrades into returning the raw ciphertext, an empty value, or a zero. We know this holds because it has fired in anger: an account whose DEK was lost during a botched deletion produced hard errors on every read until we repaired it. A system that quietly showed £0.00 instead would have been far worse.

What is encrypted, precisely

Encryption is driven by a manifest — one list, checked by tests, naming every field that gets encrypted. If a writer starts persisting a new field and nobody adds it to the manifest, that field ships in plaintext. That failure mode is not hypothetical; we found several instances of it in our own review, which is why the manifest is now guarded by tests that derive the persisted shape from the actual code rather than from a list someone maintains by hand.

DataTreatment
Transaction amounts, descriptions, merchantsEncrypted
Bank counterparty names (who you paid)Encrypted
Cash balances, opening balancesEncrypted
Institution names (who you bank with)Encrypted
Holdings: ticker, quantity, cost basis, asset nameEncrypted
Tax calculations, P60 figures, National Insurance numberEncrypted
Detected subscriptions — and their document idsEncrypted / HMAC-keyed
Insight cards, including the card typeEncrypted
Assistant conversations and saved memoriesEncrypted
Uploaded documents and offloaded tax ledgersEncrypted in object storage
Bank and broker access tokensEncrypted

Document identifiers are part of the problem

Field encryption cannot reach a document’s path. We learned this the embarrassing way: subscription documents were keyed by merchant, so a database with a perfectly encrypted merchantName field still had a document literally called …_amazonprime. Recurring payments are unusually revealing — a gym, a clinic, a dating service, a political donation — so the identifier is now an HMAC.

  BEFORE   detectedSubscriptions/{ownerId}_{portfolioId}_amazonprime
                                                        ^^^^^^^^^^^ the answer, in the path

  NOW      detectedSubscriptions/{HMAC(collection, portfolioId, merchantKey)}
           holdings/{HMAC(collection, portfolioId, assetId, accountId)}
           insights/{portfolioId}/feed/{HMAC(collection, portfolioId, type)}

  SHARE    shareLinks/{sha256(token)}      ← the token itself is never stored
Three identifier schemes. The first was a leak; the second and third are what we use now.

The share-link case is worth calling out because it changed product behaviour. A public share token used to be the document id and a stored field. Anyone who could read the database could therefore call the public endpoint and have our own server decrypt the portfolio for them — the encryption bypassed by the application itself. We now store only a SHA-256 of the token. The consequence is that a share URL can be shown once, at creation, and never re-displayed — the same property as a password reset link. We think that is the right trade, and we would rather explain it than quietly keep a credential in the database.

What is not encrypted, and why

A database has to be able to find things. Identifiers, dates and a few low-cardinality enums stay readable so the system can filter and sort without decrypting every row. This is a real trade-off, not a free one, and the honest way to present it is to say exactly what an attacker with full database read would learn.

Stays readableWhy
Owner and portfolio idsEvery query is scoped by them
Transaction dates, spendingMonthRange queries and monthly aggregation
Spending category enumAggregated server-side; ~20 possible values
Account type, currency, providerEnums; drive tax treatment and display
isCash, sync status, flagsBooleans that route logic
dedupKeyA one-way hash used to avoid duplicate imports
What that means concretely
Someone with our database and no key can tell that a user made 517 transactions in July, that some were groceries and some were transport, and that they ran a tax calculation in January. They cannot tell how much any of it was, who was paid, what is held, or what any of it is worth. We keep the plaintext set to fields that do not reveal your financial position, and Part 3 is honest about where that line is thinner than we would like.

This is not end-to-end encryption, and we will not call it that

End-to-end encryption means the server cannot read the data at all, because the keys live on your device. Signal works that way. A password manager works that way. A product that computes UK capital gains with Section 104 pooling, matches internal transfers, and answers questions about your portfolio cannot — the server has to see the numbers to do the arithmetic.

So the accurate claim is narrower and, we would argue, more useful: the plaintext exists only inside a running, attested production service, only for the duration of your request. It does not exist in the database, the backups, the logs, or on any laptop. Part 2 is about how the key is bound to that service, and what that binding does and does not prove.

A three-part series on what TruMint can see
  1. Part 1What we can and cannot read (you are here)
  2. Part 2The key that refuses to talk to us
  3. Part 3Where it stops

See it for yourself: explore a sample portfolio. No signup, no bank link.

Try the live demo