All work

Platforms & Products

The CPA Client Portal

A stolen copy of the database is a pile of ciphertext. The keys that open it are on a different machine, and that machine does not run the website.

Architected and builtNext.jsPostgreSQLAES-256-GCMOpenBao TransitAnsibleGLBA / IRS Pub 4557
  • One firm per machine, so the isolation is physical and an auditor can see the boundary.
  • No decryption key on the app host, so a stolen database is a pile of ciphertext.
  • One idempotent command stands up a tenant, which is what makes it a product rather than six bespoke builds.

The problem

A tax preparer's client list is the highest value target most people have never thought about. A stolen credit card is cancelled by lunchtime. A Social Security number is for life, and it arrives attached to an address, an employer, a spouse, and somebody's kids.

So I did not start with how to keep people out. Everyone starts there, and everyone eventually loses. I started with a worse question: when somebody does get in, what do they actually walk away with?

One firm per machine

Multi-tenant is cheaper and I did not build it that way. A shared database holding six accounting firms is one authorization bug away from a breach notification in every state those firms' clients live in. That bug is not hypothetical. Broken object level authorization is the most common serious finding in web application security, year after year, in everybody's code.

So the isolation is physical. One firm, one host, one database. It costs more per tenant, every month, forever. That cost is not overhead. It is the thing the buyer is actually purchasing, and it is demonstrable to an auditor in a way that a row level security policy never is.

What a stolen database actually yields

Every sensitive field is envelope encrypted before it is written. A per record data key encrypts the value with AES-256-GCM, and that data key is itself wrapped by a key that lives in a transit engine on another machine. The portal never holds the unwrapping key. It asks for a decrypt over an API and gets an answer, or it does not.

So the failure mode everybody actually worries about, somebody walking off with a database dump, produces this and nothing else:

v1:vault:v1:<base64 wrapped data key>:<iv>:<gcm tag>:<ciphertext>

# decryptField() must, in order:
#   1. split the token on its fixed tail (iv, tag, ciphertext)
#   2. hand the wrapped data key to the off-box key service
#   3. receive the unwrapped data key
#   4. AES-256-GCM open, authenticated, or fail
#
# step 2 is a network call to a machine that does not serve the web app.
A single encrypted field, as it sits on disk. The wrapped data key carries the vault prefix because the key that unwraps it is held by a service on a different machine. Recovering one field requires compromising the portal, the database, and the key service, which is three machines and not one. If the portal is the thing that gets compromised, and it is the internet facing part so it is the likely candidate, the attacker holds ciphertext.
What a stolen database is worthRecovering one field requires the portal, the database and the key service: three machines, not one. The internet-facing machine is the one holding no key.Portal host: internet facing, holds no keyPortal hostinternet facing, holds no keyDatabase: every sensitive field encryptedDatabaseevery sensitive field encryptedKey service: different machine, unwraps on requestKey servicedifferent machine, unwraps on requestAll three required to recover a single field.Take the database alone and you are holding ciphertext.One firm per machine, so a bug reaches one tenant rather than all of them.
Custody split
Portal hostinternet facing, holds no key
Databaseevery sensitive field encrypted
Key servicedifferent machine, unwraps on request
Isolationone firm per machine, so a bug reaches one tenant rather than every tenant
Recovering one field means compromising three machines rather than one, and the internet-facing one is the machine least likely to help. Multi-tenant would have made a single authorization bug into a breach notification in every state those clients live in.

The signature stays in the building

Engagement letters and consent forms are signed by a service I run, not rented from a signature vendor. That is a deliberate call about custody: the signed artifact and its audit trail both stay inside the tenant boundary instead of living in somebody else's account, under somebody else's breach notification policy. It is the same reasoning as the keys.

The discipline, applied everywhere

  • Multi-factor authentication is mandatory, not offered.

  • Every access path checks authorization per record. The classic failure here is an object reference that trusts the id in the URL, and it is classic because it keeps working.

  • Access is logged, not just change. Knowing who read a return matters as much as knowing who edited one.

  • No sensitive data in notifications. An email preview on a lock screen is an uncontrolled copy of something you promised to protect.

  • No real client data in fixtures, tests, logs, or commit messages. Not once, not for debugging.

  • A migration against a live tenant database is a live host change: a declared window, and a restore path proven before the migration runs, not after it fails.

Engineering decisions

The calls I made, and what each one cost.

Single tenant on a dedicated host, not multi-tenant SaaS.

It costs more per firm every month and it caps the blast radius of any single bug at one firm. An auditor can see the boundary. Nobody has ever been talked out of a breach by a well written policy.

Key custody off the application box, always.

The threat model for a portal holding tax records includes the portal being compromised. If the keys sit on that host, the encryption is decoration that makes everyone feel better.

Provisioning a tenant is one idempotent command.

Mount, per service keys, policies, credentials. If standing up a new firm is a two day ceremony I get creative every time, and creative is how tenants end up configured differently from each other. Repeatable is what makes it a product instead of six bespoke builds wearing a trench coat.

More systems

I'm looking for Incident Commander and SRE roles.

If your team is drowning in toil, alert noise, or incidents that never quite close. That is the work I do.

Get in touch