← Finlynq blog

How Finlynq encrypts your money

Envelope encryption, in plain English · Published 2026-05-13

If your AI assistant can read your money, who else can? That is the question I had to answer for myself before I felt okay handing a language model the keys to my real bank data. This post is the honest answer for Finlynq — what is encrypted, what is not, what tradeoffs I accepted, and where you can read the code that implements it.

I built Finlynq partly because I wanted a personal-finance app that an AI could query without me having to email a CSV to a chatbot. The unavoidable second question once you build that is: how do you keep the operator (me) honest? Finlynq runs on a single VPS I own. If I wanted to read your transactions, what would stop me?

The answer is per-user envelope encryption with a key derived from your password, and it has real teeth. It also has real limits. Both halves are in this post.

1. The threat model — what we are and aren't protecting against

Encryption schemes only make sense relative to a threat. Here are the threats Finlynq's design takes seriously:

  • Stolen database dump. Someone gets read access to the Postgres database — disk image, pg_dump, or an unauthorized backup copy. They should not be able to read your merchant names, account names, notes, tags, or categories from that dump alone.
  • Stolen database and server filesystem. An attacker has the DB plus the server's environment variables. The pepper helps (more below), but they would still need to brute-force every user's password individually. That's slow on purpose.
  • Stale backups in cloud storage. Database backups are encrypted on disk with a symmetric key kept off the host, so a copy floating around a backup bucket does not equal a breach.
  • Cross-tenant data leaks.One user's data should never become readable by another user via a buggy import, backup restore, or account wipe.

Here are the threats Finlynq does not claim to defend against — and you should know this before you trust the system:

  • A malicious or compromised operator at runtime. When you are signed in, your decryption key is held in the server's memory. An attacker who roots the server while you are using it can read it. There is no honest way around this for a web app that responds to your queries server-side; only a true client-side-only design avoids it, and that comes with its own set of compromises (no server-side aggregation, no MCP, no AI assistant access).
  • The amounts and dates of your transactions. These are stored as plain numbers and dates in the database. They must be — otherwise the app could not sum your spending, compute a budget, or feed an AI assistant a portfolio analysis without your browser doing all the math. The operator can see anonymized amounts and dates. They are useless without the labels, but they are not encrypted.
  • The structure of your data. The fact that you have 14 accounts, 320 transactions a month, and a 7.4% savings rate is visible to the operator. Just not what any of them are for.
  • Side-channel inference.If your “Account #3” has a recurring $1,847.00 charge on the 15th of every month and you live in a major city, a determined operator could guess “that's probably rent.” Encryption does not stop guesses. It stops reading.
  • A subpoena. Operators get subpoenaed. Finlynq is operated from Canada and we have a privacy policy with retention rules, but a court order is a court order — what we could be compelled to hand over is the encrypted data plus whatever metadata the database contains. Without your password, even we cannot read the labels.

If any of the second list is a dealbreaker for you, self-hosting is the right answer. Finlynq is AGPL v3 — the same code runs on your laptop or homelab as on our managed cloud. The full self-hosting guide is at /self-hosted.

2. The architecture in plain language

The pattern Finlynq uses is called envelope encryption. It's the same shape AWS KMS, Google Cloud KMS, and most password managers use. Two keys, not one:

  1. A Data Encryption Key (DEK) — 32 random bytes, one per user, generated the moment you sign up. This is the key that actually encrypts your fields.
  2. A Key Encryption Key(KEK) — derived from your password every time you sign in. The KEK's only job is to wrap and unwrap the DEK.

The DEK never leaves the server, but it's only useful when unwrapped, and unwrapping requires your password. Here's the sequence in slightly more detail.

When you sign up:

  • Finlynq generates a fresh, random 32-byte DEK using the OS random source.
  • It also generates a 16-byte random salt and runs your password through scrypt with parameters N = 216, r = 8, p = 1— roughly 64 MB of memory and ~80 ms of compute per derivation on modern hardware. That's the KEK.
  • The DEK is wrapped (encrypted) with the KEK using AES-256-GCM, and the wrapped DEK plus the salt are stored in your users row.
  • The raw KEK is discarded the moment the wrap finishes. The database now contains a wrapped DEK and a salt — and nothing on the server can unwrap that DEK without your password.

When you sign in:

  • You send your password over HTTPS. The server checks it against the stored hash, then re-runs scrypt with your stored salt to re-derive your KEK.
  • It uses the KEK to unwrap your DEK and caches the raw DEK in memory, keyed by your session id.
  • The KEK is again discarded immediately. The cache holds only the DEK, and only for the lifetime of your session — with a sliding 2-hour idle timeout. If you walk away from your laptop for the afternoon, your DEK is gone from memory by the time you come back, and the next request decrypts nothing until you sign in again.

When the app reads or writes a sensitive field:

  • For every encrypted column on every row, Finlynq runs AES-256-GCM with a freshly-random 12-byte IV. The output is stored as v1:<base64 iv>:<base64 ciphertext>:<base64 auth-tag>. The v1: prefix is a version marker so we can rotate schemes later without ambiguity.
  • GCM is an authenticatedencryption mode — every row has a 16-byte authentication tag that's checked on decrypt. If a single bit of the ciphertext was tampered with, decryption fails loudly instead of returning subtly wrong plaintext.
  • Random IV per row means even if two transactions have the exact same payee name, their ciphertexts are completely different. The operator can't even tell that “you shop at the same place twice.”

One extra detail worth calling out: the password input to scrypt is not the raw password. It's HMAC-SHA256(server-pepper, password), where the pepper is a long random secret stored in the server's environment and never in the database. The pepper exists to defend against a database-only leak: even a stolen DB plus a 1080 Ti can't mount an offline scrypt-cracking run without also stealing the pepper out of the server's environment. The pepper is not a user-facing feature — losing it is the same as losing the DB — but it raises the bar against database-only theft, which is the most common breach shape.

The full key-derivation code is at pf-app/src/lib/crypto/envelope.ts. It's about 280 lines including comments. AGPL v3, read it yourself.

3. What this means in practice

Here is what the operator (me) can and cannot see when I open a psql shell against the production database:

I cannot decrypt:

  • The payee on any transaction.
  • The free-text note on any transaction or split.
  • The tags on any transaction.
  • The display names of your accounts, categories, goals, loans, subscriptions, and portfolio holdings. These were the last plaintext labels in the database — they were physically dropped from the schema on 2026-05-03 in a project we called Stream D Phase 4. The plaintext columns are gone; only the encrypted versions remain.
  • The encrypted attachment of any receipt you upload to the file store.
  • The aliases you assigned to your accounts.

I can see:

  • The numeric amount of every transaction, and the currency code.
  • The transaction date and the date the row was created or last updated.
  • The integer foreign keys that connect a transaction to an (encrypted-name) account and an (encrypted-name) category.
  • Whether a row is a regular transaction, a transfer, an income, or an expense — the one-character type column (E / I / R / T) is plaintext because the category-vs-sign invariant has to be checked server-side.
  • How many accounts, categories, goals, and holdings you have, and the structural shape of your portfolio (counts, dates, integer IDs).

In other words: I can see “there's a $42.18 expense on 2026-04-09 in category #14, account #3.” I cannot see what category #14 is, what account #3 is, who the payee was, or what note you wrote on it. The amounts and dates are visible. The labels are not.

This is the honest version of the privacy claim. The landing page says “Mathematically private,” and that's true about the labels— they really are sealed by a key derived from your password. But it overstates the case if you read it as “the operator sees nothing.” The operator sees plenty. The operator just can't read the labels that turn those numbers into meaningful information about your life.

4. The honest tradeoffs

Three tradeoffs worth being explicit about.

Tradeoff 1: lose your password, lose your data. Finlynq has no recovery key, no admin override, no master decryption key kept on ice for emergencies. If you forget your password, the “reset” flow does the only thing it cryptographically can: it wipes all your data and provisions a fresh DEK under your new password. There is no way to call support and recover what was in there. This is by design — any recovery mechanism would necessarily mean Finlynq holds something that can decrypt your data, which is exactly what we're promising isn't the case.

This is a real cost. People do forget passwords. The mitigation is simple but boring: pick a password from a password manager, write it down somewhere physically secure, and export an unencrypted JSON backup to your own machine periodically (Settings → Data → Export). Finlynq can't save you from losing your password, but you can save yourself.

Tradeoff 2: amounts and dates are not encrypted. Some personal-finance apps encrypt the amounts too, computing all aggregations in the browser. That's a defensible design — it shrinks what the operator can see — but it also makes the things Finlynq cares most about (server-side MCP tools, aggregate queries from an AI assistant, multi-currency conversion, the FIRE calculator) either impossible or very slow. We made the call that the value of an AI being able to answer “what was my total spend last month?” server-side outweighs the marginal privacy cost of the operator seeing un-labelled amounts.

If you disagree with that call — and reasonable people do — the answer is self-hosting. When you self-host, “the operator sees the amounts” collapses to “you see the amounts,” which presumably is fine.

Tradeoff 3: deploys briefly degrade the read path. The DEK cache lives in process memory, so when Finlynq restarts — for a deploy, a crash, a maintenance window — every signed-in user momentarily has a valid session cookie but no cached DEK on the server. Rather than 503ing every page until everyone re-logs in, read paths handle this gracefully: encrypted fields render as a placeholder, the app keeps working, and the next sign-in restores normal display. Writes that need the key block until you re-sign-in, because silently writing plaintext into encrypted columns would be worse than blocking. There's also a deploy-generation marker that proactively invalidates old sessions across a deploy boundary so you get a clean re-auth instead of a degraded one.

5. Why this matters for the AI-in-finance question

Finlynq's pitch is “track your money here, analyze it anywhere.” The “anywhere” is the Model Context Protocol server — 91 tools that let Claude, ChatGPT, Cursor, or any other MCP-compatible AI assistant query and mutate your financial data on your behalf.

The encryption model matters here because of a question every cautious user asks before they connect an AI to their bank data: where does the data actually go, and who sees it?

The answer for Finlynq is layered:

  • Your raw datalives in Finlynq's database, with the labels encrypted at rest as described above.
  • When an AI assistant calls an MCP tool, it authenticates with either OAuth 2.1, a Bearer API key, or stdio. The server unwraps your DEK on that request, decrypts only what that tool needs to return, and the tool's response goes back to the AI as plaintext JSON. The AI provider (Anthropic, OpenAI, whoever) does see that response — that is unavoidable if you want the AI to answer questions about it.
  • That MCP session is scoped: it gets read-only or read-write tools according to the OAuth scope you granted; you can revoke the grant from Settings → Connected apps at any time; destructive operations require a preview-then-confirm cryptographic token so the AI cannot mutate your data without your explicit step.
  • We do not train models on your data. The MCP server is a tool gateway, not an ingest pipeline. Anything that crosses the AI vendor's API is governed by their privacy policy — refer to Anthropic's or OpenAI's for the details — but the connection from your data to that vendor is one you explicitly authorize and can revoke.

So if you're worried about AI assistants getting access to your financial life: the answer isn't “never grant the access.” The useful answer is “grant a scoped, revocable, observable session, and use a backend that can't read the data on its own.” That second half is what the encryption model buys you.

6. Where to learn more

Everything in this post is described in more rigorous detail in the architecture docs, and the code that implements it is published under AGPL v3:

  • pf-app/docs/architecture/encryption.md — the authoritative technical reference. Covers Phase 2, Phase 3, and the Stream D rollout that finally encrypted display names; the auth-tag failure resilience helper that prevented a class of regressions; the wipe-account primitive; the backup-restore foreign-key remap; the grace migration for pre-encryption accounts.
  • pf-app/src/lib/crypto/ — the implementation. Roughly 1,500 lines across envelope, key cache, column helpers, staging envelope, file envelope. Small enough to read in an afternoon.
  • STREAM_D.md — the design doc for the display-name encryption rollout. Useful if you want to understand the parallel (name_ct, name_lookup) column pattern that lets encrypted strings still support exact-match SQL queries and per-user unique constraints.
  • /privacy — the policy version of all this, with GDPR Article 30 records, retention rules, and the sub-processor list.
  • /self-hosted — if the “trust the operator” layer is the part you want to skip, run Finlynq on your own hardware. Same code, same encryption, you're the operator.

And if you find something in the design or the code that's wrong — or weaker than this post claims — please tell me. Email privacy@finlynq.com, or open an issue at github.com/finlynq/finlynq/issues. An honest threat model is more useful than a confident one, and the only way it stays honest is if people who know more than me keep poking holes.

Hussein Halawi, founder · 2026-05-13. Corrections welcome.