← 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? I had to answer that for myself before I felt okay handing a language model the keys to my real bank data. So here's the honest answer for Finlynq. What's encrypted, what isn't, the tradeoffs I made my peace with, and where you can go read the code that does it.

I built Finlynq partly because I wanted a personal-finance app an AI could query without me emailing a CSV to a chatbot. But the moment you build that, a second question shows up and won't leave: 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 actually stop me?

The answer is per-user envelope encryption with a key derived from your password. It has real teeth. It also has real limits, and I'd rather you hear about both from me. So both are in this post.

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

Encryption only means something if you say what it's protecting against. So here are the threats Finlynq's design actually takes seriously:

  • Stolen database dump. Someone gets read access to the Postgres database. A disk image, a pg_dump, an unauthorized backup copy, whatever. 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. Now an attacker has the DB plus the server's environment variables. The pepper helps here (more on that below), but they would still have to brute-force every user's password one at a time. 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 some backup bucket isn't a breach by itself.
  • Cross-tenant data leaks.One user's data should never become readable by another user. Not through a buggy import, not a backup restore, not an account wipe.

And here are the threats Finlynq does not claim to defend against. You should know these before you trust the system:

  • A malicious or compromised operator at runtime. When you're signed in, your decryption key is sitting in the server's memory. An attacker who roots the box while you're using it can read it. I'm not going to pretend there's a clean way around this for a web app that answers your queries server-side. Only a true client-side-only design dodges it, and that comes with its own pile of compromises (no server-side aggregation, no MCP, no AI assistant access).
  • The amounts and dates of your transactions.These live in the database as plain numbers and dates. They have to. Otherwise the app couldn't sum your spending, build a budget, or hand an AI a portfolio analysis without your browser doing all the math. So the operator can see anonymized amounts and dates. They're useless without the labels, but they're not encrypted, and I'm not going to dress that up.
  • 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 it is 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 big city, a determined operator could guess “that's probably rent.” Encryption doesn't stop guesses. It stops reading.
  • A subpoena.Operators get subpoenaed. Finlynq is run from Canada and we have a privacy policy with retention rules, but a court order is a court order. What we could be forced to hand over is the encrypted data plus whatever metadata the database holds. Without your password, even we can't read the labels.

If anything in that second list is a dealbreaker for you, self-hosting is the right answer. Finlynq is AGPL v3, so the exact 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 fresh from your password every time you sign in. Its only job is to wrap and unwrap the DEK.

The DEK never leaves the server, but it's only useful once it's unwrapped, and unwrapping it takes your password. Here's the sequence in a bit more detail.

When you sign up:

  • Finlynq generates a fresh, random 32-byte DEK straight from 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. That's roughly 64 MB of memory and around 80 ms of compute per derivation on modern hardware. The result is the KEK.
  • The DEK gets wrapped (encrypted) with the KEK using AES-256-GCM, and the wrapped DEK plus the salt go into your users row.
  • The raw KEK is thrown away the instant the wrap finishes. So now the database holds a wrapped DEK and a salt, and nothing on the server can unwrap that DEK without your password. Nothing.

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 that KEK to unwrap your DEK and caches the raw DEK in memory, keyed by your session id.
  • The KEK gets discarded again, right away. The cache holds only the DEK, and only for the life of your session, with a sliding 2-hour idle timeout. Walk away from your laptop for the afternoon and your DEK is already gone from memory by the time you come back. 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>. That v1: prefix is just a version marker, so we can rotate schemes down the road without any guesswork.
  • GCM is an authenticated encryption mode. Every row carries a 16-byte authentication tag that gets checked on decrypt. Flip a single bit of the ciphertext and decryption fails loudly, instead of quietly handing back wrong plaintext.
  • A random IV per row means even if two transactions have the exact same payee name, their ciphertexts look nothing alike. The operator can't even tell that “you shop at the same place twice.”

One more detail worth calling out. The password fed into scrypt isn't the raw password. It's HMAC-SHA256(server-pepper, password), where the pepper is a long random secret that lives in the server's environment and never touches the database. The whole point of the pepper is to blunt a database-only leak: even a stolen DB plus a 1080 Ti can't mount an offline scrypt-cracking run without also lifting the pepper out of the server's environment. It's not a user-facing thing, and losing it is the same as losing the DB. But it raises the bar against database-only theft, which is by far the most common breach shape.

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

3. What this means in practice

Here's exactly what the operator (me) can and can't see when I open a psql shell against the production database:

What 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 left in the database, and they got physically dropped from the schema on 2026-05-03 in a project we called Stream D Phase 4. The plaintext columns are gone now. Only the encrypted versions remain.
  • The encrypted attachment of any receipt you upload to the file store.
  • The aliases you gave your accounts.

What I can see:

  • The numeric amount of every transaction, and the currency code.
  • The transaction date, plus the date the row was created or last updated.
  • The integer foreign keys that tie 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. That one-character type column (E / I / R / T) stays plaintext, because the category-vs-sign invariant has to be checked server-side.
  • How many accounts, categories, goals, and holdings you have, and the overall shape of your portfolio (counts, dates, integer IDs).

Put another way: I can see “there's a $42.18 expense on 2026-04-09 in category #14, account #3.” I can't see what category #14 is, what account #3 is, who the payee was, or what note you scribbled 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 things 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 anything meaningful about your life.

4. The honest tradeoffs

Three tradeoffs I want to be flat-out about.

Tradeoff 1: lose your password, lose your data. Finlynq has no recovery key, no admin override, no master decryption key sitting 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 hands you a fresh DEK under your new password. There's no calling support to recover what was in there. And that's on purpose. Any recovery mechanism would mean Finlynq is holding something that can decrypt your data, which is the exact thing we're promising isn't true.

This is a real cost. People forget passwords, it happens. The fix is simple, if boring: pick a password from a password manager, write it down somewhere physically safe, and now and then export an unencrypted JSON backup to your own machine (Settings → Data → Export). Finlynq can't save you from losing your password. But you can save yourself.

Tradeoff 2: amounts and dates aren't encrypted. Some personal-finance apps encrypt the amounts too and do all the aggregation in the browser. That's a defensible design. It does shrink what the operator can see. But it also makes the things Finlynq cares most about (server-side MCP tools, aggregate queries from an AI, multi-currency conversion, the FIRE calculator) either impossible or painfully slow. So I made the call: an AI being able to answer “what was my total spend last month?” server-side is worth more than the slim privacy gain of hiding un-labelled amounts from the operator.

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

Tradeoff 3: deploys briefly degrade the read path. The DEK cache lives in process memory, so whenever Finlynq restarts (a deploy, a crash, a maintenance window) every signed-in user suddenly has a valid session cookie but no cached DEK on the server. Instead of 503ing every page until everyone logs back in, the read paths handle it gracefully: encrypted fields render as a placeholder, the app keeps working, and your next sign-in puts everything back to normal. Writes that need the key block until you re-sign-in, because silently writing plaintext into encrypted columns would be a lot worse than blocking. There's also a deploy-generation marker that proactively kills old sessions across a deploy boundary, so you get a clean re-auth instead of a half-broken one.

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

Finlynq's pitch is “track your money here, analyze it anywhere.” The “anywhere” part is the Model Context Protocol server, with 109 HTTP tools (93 over stdio) that let Claude, ChatGPT, Cursor, or any other MCP-compatible AI assistant query and change your financial data on your behalf.

The encryption model matters here because of the one question every cautious person asks before they wire an AI up to their bank data: where does the data actually go, and who gets to see it?

For Finlynq, the answer comes in layers:

  • Your raw datalives in Finlynq's database, with the labels encrypted at rest, exactly 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 for that request, decrypts only what the tool actually needs to return, and the tool's response heads back to the AI as plaintext JSON. The AI provider (Anthropic, OpenAI, whoever) does see that response. There's no way around it if you want the AI to answer questions about your data.
  • That MCP session is scoped. It gets read-only or read-write tools based on the OAuth scope you granted, you can revoke the grant from Settings → Connected apps whenever you like, and destructive operations need a preview-then-confirm cryptographic token, so the AI can't change your data without you taking an explicit step.
  • We don't 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, so check Anthropic's or OpenAI's for the specifics. But the link between your data and that vendor is one you explicitly turn on, and can turn off.

So if you're nervous about AI assistants reaching into your financial life, the answer isn't “never grant the access.” The useful answer is “grant a scoped, revocable, observable session, and run it on a backend that can't read the data on its own.” That second half is the part the encryption model buys you.

6. Where to learn more

Everything in this post is spelled out in more rigorous detail in the architecture docs, and the code behind it is published under AGPL v3:

  • pf-app/docs/architecture/encryption.md is the authoritative technical reference. It covers Phase 2, Phase 3, and the Stream D rollout that finally encrypted display names, plus the auth-tag failure resilience helper that headed off a whole class of regressions, the wipe-account primitive, the backup-restore foreign-key remap, and the grace migration for pre-encryption accounts.
  • pf-app/src/lib/crypto/ is the implementation. Roughly 1,500 lines across envelope, key cache, column helpers, staging envelope, and file envelope. Small enough to read in an afternoon.
  • STREAM_D.md is the design doc for the display-name encryption rollout. Worth a look 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 is the policy version of all of 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'd rather skip, run Finlynq on your own hardware. Same code, same encryption, except now you're the operator.

And if you spot something in the design or the code that's wrong, or just 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 beats a confident one every time, and the only way it stays honest is if people who know more than I do keep poking holes in it.

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