Torv

Security

Most financial apps say “bank-level encryption” and show a lock icon. We’d rather show you our actual database.

What our accounts table actually looks like

This is a representative sample of how your account data is stored. Amber = AES-256-GCM encrypted. Blue = HMAC-SHA256 pseudonymized. Black = plaintext.

user_idnameinstitutionbalancetype
a1b2c3d4e5f6...9f8e7d6cenc:3a7f1b:c9e2d4:8b1f3a7e...enc:7d4e2a:f1b3c5:2e9a4d7b...$47,832.19Checking
a1b2c3d4e5f6...9f8e7d6cenc:5c8d2e:a4f1b7:6d3e8c1a...enc:9b2f4a:d7e3c1:4a8f2b5e...$312,457.83401k
a1b2c3d4e5f6...9f8e7d6cenc:2f9a4d:b5c8e1:7a3f6d2b...enc:4e1b7c:f8a2d6:5c9b3e7a...$184,291.50Mortgage

What our transactions table actually looks like

Transactions store amounts, descriptions, and categories in plaintext — they’re needed for real-time aggregation, tax calculations, and financial statements. But there is no name, email, or login credential anywhere in this table.

user_iddescriptionamountdatecategory
a1b2c3d4e5f6...9f8e7d6cCOSTCO WHSE #1234-$247.832026-02-24Groceries
a1b2c3d4e5f6...9f8e7d6cEMPLOYER DIRECT DEP$4,832.192026-02-15Income
a1b2c3d4e5f6...9f8e7d6cVANGUARD BUY-$2,000.002026-02-16Contribution/Withdrawal

What the encryption achieves

Stored in plaintext

  • • Financial amounts (balances, transaction amounts)
  • • Transaction descriptions and dates
  • • Account types (checking, 401k, mortgage)
  • • Categories and rules

Encrypted or not stored at all

  • • Your email and any other identifying field (encrypted with AAD)
  • • Which bank any account belongs to (encrypted)
  • • Account names and owner names (encrypted)
  • • Bank login credentials (managed by our aggregation provider, not us)
  • • MFA secrets and recovery codes (encrypted; codes also one-time)

The critical insight: amounts alone are not identifying. Linking the financial rows in the database back to a real person requires the AES-256-GCM encryption key — stored separately from the database, in a secrets vault that no single backup, snapshot, or replica contains.

Four layers of protection

1

Decoupled identity

Every user is identified internally by a randomly-generated UUID, not by your email or any value derived from your real identity. The financial tables store that UUID; the email column on the users table is encrypted (see layer 2). Linking a UUID back to a real person requires both database access AND the encryption key — which is stored separately, outside the database.

Internal user ID: a7f3b2c1-4d5e-6f78-9abc-def012345678
Email column: enc:v2:3a7f1b9c2e4d:c9e2d4f1b3a7:8b1f3a7e2c4d9b5f...

For deterministic email lookup at sign-in we also store an HMAC-SHA256 hash of your email; the HMAC secret lives outside the database, so a database breach cannot turn the hash back into your address.

2

PII encryption

Every personally identifiable field — account names, institution names, owner names, and bank connection tokens — is encrypted with AES-256-GCM before it touches the database. Each value gets a unique random initialization vector and a tamper-proof authentication tag. Each ciphertext is also bound to its row identity (table, row id, column) via AES-GCM associated data, so an attacker with database write access cannot move an encrypted value from one row or column onto another.

Account name: Chase Sapphire Checking
Stored as: enc:3a7f1b9c2e4d:c9e2d4f1b3a7:8b1f3a7e2c4d9b5f...

AES-256-GCM is the same standard used by governments and financial institutions worldwide. The “GCM” part means any tampering with the encrypted data is detected automatically.

3

Credential isolation

Your bank login credentials never enter our system. When you connect a bank, you authenticate directly with your bank in a secure widget provided by our aggregation partner (Quiltt, powered by MX and Finicity). Torv receives only an encrypted access token and read-only transaction data. We cannot log into your bank, move money, or see your password.

4

Database-level tenant isolation

Even if application code forgot to filter a query by user, the database itself would reject it. The application connects to Postgres as a restricted role (torv_app) with row-level security enforced on every table that holds user data. Each request sets the active user’s pseudonymized ID as session context; the database returns only rows tagged with that ID.

A second role (torv_admin) is reserved for trusted background jobs (sync, cron) where there is no user request to scope to, and is never bound to a request that originates from a browser.

Technical details

Identity hashingHMAC-SHA256 with 256-bit secret
PII encryptionAES-256-GCM with 96-bit random IV per value; ciphertext bound to (table, row id, column) via AES-GCM associated data
AuthenticationCustom auth with bcrypt-hashed passwords + JWT sessions; TOTP MFA and WebAuthn passkeys both supported
Data in transitTLS 1.3 (HTTPS everywhere)
Data at restDisk encryption at rest + application-level AES-256-GCM
Key storageEncryption keys stored in Cloudflare Workers secrets, separate from database
Bank credentialsManaged by aggregation provider — never stored by Torv
API authorizationPostgres row-level security on every PII table (torv_app role); per-request session context binds queries to the active user’s pseudonymized ID
Key rotationEncryption keys are versioned and rotatable; the previous key is retained during transition so existing ciphertexts remain decryptable while new writes use the current key
BackupsContinuous Postgres WAL archive to encrypted off-site storage (Cloudflare R2); point-in-time recovery to any second within the retention window
Content Security PolicyStrict CSP with violation reporting; no inline scripts, no third-party origins beyond what aggregation requires
TrackingNo cookies, no third-party analytics, no ad trackers

Why aren’t amounts and descriptions encrypted?

Honest answer: because the product wouldn’t work.

Torv generates financial statements, computes taxes across all 50 states + DC, runs retirement projections, and categorizes transactions — all on the server. These operations require the database to aggregate, filter, and sort financial amounts. If amounts were encrypted, every query would require decrypting the entire database into memory first.

Instead, we encrypt the fields that link data to identity (names, institutions) and pseudonymize the field that links data to a login (user ID). The result:

  • Amounts without identity are anonymous. Someone spent $247.83 at Costco. Who? No way to tell from the database alone.
  • Encrypted names without amounts are useless. Someone named “enc:3a7f...” exists. So what?
  • Linking the two requires a secret stored nowhere in the database.

This is the same principle used by medical research databases and financial institutions: separate identity from data, protect the link between them.

Reporting a vulnerability

If you’ve found a security issue, please report it through our contact form. We respond to every report.