SWE Case Study
Kyron Medical
Field-level AES-256-GCM encryption applied at the ORM boundary, with a read-path decrypt helper that never trusts the caller to remember it.
Snapshot
At a Glance
Role
Dir. Eng.
Tech Consultant -> Director of Technical Engineering
Duration
15 mo
Nov 2024 - Feb 2026
Stack
Next.js + tRPC
Prisma . AES-256-GCM . AWS
Scale
$5M+
Healthcare claims analyzed
01
Context
The product is a HIPAA-regulated healthcare platform. Several columns in the Postgres schema hold protected health information: patient names, identifiers, claim references. By policy, those fields must remain unreadable at rest. A database breach alone, or a stolen replica, must not be sufficient to expose any record.
I came in as Technical Consultant and grew into Director of Technical Engineering. The encryption design and rollout across the schema fell to me: which columns were sensitive, how the cipher envelope would be shaped, where in the request lifecycle the transform happened, and how the read path stayed honest as the schema kept growing. The constraint was simple: the team should not need to remember to call an encrypt or decrypt function. The system should make the wrong thing hard to write.
02
What I Built
I implemented field-level AES-256-GCM at the ORM boundary. Sensitive Prisma columns store a JSON envelope of the form { data, iv, tag } rather than plaintext. The symmetric key is KMS-backed, fetched once at server boot via env vars and held in memory; it never ships in source, and the application never writes it to disk. The envelope shape is intentionally boring — three opaque bytes and two metadata fields — so a database export, a logging mistake, or a misrouted backup leaks ciphertext, not records.

On writes, the encrypt step lives inside the mutation, called immediately before the corresponding prisma.{model}.{create,update}. The mutation never sees the plaintext after that point. Encrypt happens after the auth check has already passed — there is no ciphertext flowing for unauthenticated callers, and there is no plaintext left in the request scope by the time Prisma is asked to persist anything.
On reads, every tRPC procedure that returns a sensitive entity funnels through decryptRecord(). The helper takes the envelope, pulls the in-memory key, and returns the plaintext shape the client expects. This is the disciplined choice of the design: the helper is wired into the read path itself, not exposed for the caller to remember to invoke. New tRPC routes can't accidentally return the envelope shape to React because the typed query response is the decrypted shape; forgetting the helper is a type error, not a silent data leak.
Other engineering at Kyron
03
Hard Parts
Legacy plaintext during migration
The rollout was not a hard cutover. For the duration of the migration window the read path had to handle both shapes: legacy plaintext rows from before the encryption work landed, and encrypted envelopes from after. I built a parser that detects the shape first — typeof value === "object" && value.iv — and only then attempts to decrypt; otherwise it returns the value unchanged. Tradeoff: every read path pays a small parse cost forever, even after every row has been migrated. I took that cost in exchange for not running a hard cutover that risked data loss on records being modified mid-migration.
Auth check ordering
The ordering rule that took the longest to write down: encrypt must happen after the auth check on writes, and decrypt must happen before the ownership filter on reads. Decrypting after ownership filtering sounds defensive — it isn't. If the filter rejects a record before decryption, the response time leaks whether a record exists, because decryption is the expensive step. Decrypt unconditionally, then filter; the response time is constant whether the record belongs to the caller or not. The complementary decision was the error code: ownership failures throw NOT_FOUND rather than NOT_AUTHORIZED, so the caller cannot probe for record existence by reading error codes either.
Search across encrypted fields is impossible
The honest answer to "can we search by patient name?" is: not on the encrypted column, ever. Full-text search and equality lookups against AES-256-GCM ciphertext do not work, because the same plaintext encrypts to different bytes each time (the IV is per-record). I did not try to fix this with deterministic encryption or blind indexes — those reintroduce the leakage class the encryption was meant to close. The decision was to keep search on a separate non-PHI projection table and raise the cost of any "search by sensitive field" feature back to product. Tradeoff stated plainly: search latency on non-PHI columns is unchanged, but the system loses the ability to search across PHI-bearing fields without a deliberate, separately reviewed pipeline.
04
What I'd Change
The cleanest thing I'd do differently is separate decrypt from the read query itself. The current pattern bundles the two: the tRPC procedure runs the query and the decrypt step in the same call, and the response is the decrypted shape. That is ergonomic, but it makes paginated decrypt-on-page-only harder than it should be — every read path decrypts the full result set, even when the UI only renders the first ten rows.
With more time, I'd push decrypt into a tRPC middleware that runs lazily on the response object: the procedure returns envelopes, the middleware decrypts only the rows the caller actually consumes. The transform stays invisible to the caller, but the cost moves from "decrypt the whole page" to "decrypt the rows that get read." The current shape is correct; it just charges more than it has to.
Back to Case Studies
Built at Kyron Medical.