Selling Fortune-100 plans on paper
An insurance operator sold Fortune-100-level PPO health plans to people who normally can’t get them: sole proprietors, retirees, the self-employed, grouped under a PEO co-employment model so they qualified as one large-group risk pool. The product was strong. The enrollment was a filing cabinet. Every application was hand-keyed, there was no central system of record, and each quarterly rate change meant someone rebuilt the marketing collateral by hand.
Two facts shaped everything that followed. The data was about as sensitive as consumer data gets, Social Security numbers and payment details sitting in the same form, so the build had to be compliant and secure from day one. And pricing had to move on a quarterly cadence without re-pricing anyone already enrolled, while both a marketing site and a transactional app quoted from it. If those two ever drifted apart, the company would be advertising one number and charging another.
One price, two subdomains
The marketing site stayed on WordPress, where the client’s team could edit copy without waiting on a deploy. Everything transactional moved to a Next.js and TypeScript application on PostgreSQL. The seam between them is a single pricing API. The WordPress quote form and the React enrollment flow both read /api/pricing, so a quarterly rate change is published once and appears in both places at the same moment, with no second copy of the numbers to fall out of sync.
The enrollment flow and the admin portal are the same Next.js deployment, kept apart by subdomain. A middleware rewrite reads the incoming host (from X-Forwarded-Host, since Railway sits in front as a proxy and the real host never reaches the app any other way) and routes enroll.* into the enrollment flow and admin.* into the dashboard. One build, one dependency tree, two front doors that never see each other’s routes.
An SSN you can’t leak
One sentence sits under all of the care that follows: Social Security numbers are encrypted before they ever reach storage, and decrypted only in the admin context. Nothing else in the platform handles a number in the clear.
The encryption is the easy half. The hard half is that an encrypted SSN still has to be findable. Support pulls up an enrollee by their number, the database rejects a second enrollment on the same one, and neither of those can afford to decrypt a column and scan it row by row. A number you can search is a number you have weakened. So each SSN takes two paths at once.
On the storage path the number is sealed with AES-256-GCM under a fresh random 12-byte IV, carrying a constant additional-authenticated-data tag (ppoplan:ssn:v1) that binds the ciphertext to this field and this version, so a blob lifted from somewhere else fails to authenticate. Because the IV is random, encrypting the same SSN twice yields two different blobs. That is exactly the property that makes the stored value safe and, at the same time, useless as a key you could look anything up by.
The lookup path answers that separately. Alongside the blob, the record keeps a blind index: a salted HMAC-SHA256 of the same SSN, deterministic, so identical numbers always land on the same digest. Searches and the uniqueness constraint run against the index. The stored SSN itself is never touched to find a record.
Rotation was designed in on the first day rather than bolted on after an incident. Every blob carries the key version that sealed it, in a payload shaped like ppo:v2:{iv}:{tag}:{ciphertext}. Decryption reads the version off the front and pulls the matching key from a keyring held in memory, a Map of version to key, with retired keys loaded from an environment list so older rows stay readable. Rotating a key means promoting a new current version and letting the previous one decrypt until a background job has re-sealed every row. It is a migration, not a schema change.
import { createCipheriv, randomBytes, createHmac } from 'node:crypto';
const AAD = Buffer.from('ppoplan:ssn:v1'); // ties the ciphertext to this field + version
const keyring = loadKeyring(); // Map<version, key>, retired keys included
const b64 = (b: Buffer) => b.toString('base64url');
// One SSN, two outputs. The GCM blob is non-deterministic (fresh IV each call), so it
// can never serve as a lookup key. The HMAC index is deterministic, so a record is
// found by SSN without decrypting the stored blob. The version in the payload is what
// lets a key rotate: decryption reads v{N} and pulls that key from the ring.
export function sealSSN(ssn: string) {
const { version, key } = keyring.current;
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key, iv).setAAD(AAD);
const ct = Buffer.concat([cipher.update(ssn, 'utf8'), cipher.final()]);
const blob = ['ppo', 'v' + version, b64(iv), b64(cipher.getAuthTag()), b64(ct)].join(':');
const index = createHmac('sha256', keyring.indexSalt).update(ssn).digest('base64url');
return { blob, index }; // blob to the encrypted column, index to the unique lookup column
} The fork inside that function, one input leaving as two outputs, is the piece I would defend line by line in a review. It is what lets the platform hold a number it can prove it is protecting and still answer “have we seen this person before” in a single indexed query.
Revoking an admin in one request
Admin authentication runs on better-auth: JWT-based session auth with refresh, an idle timeout, and rate-limited sign-in. One choice there went against the usual performance advice, and it was the right call.
better-auth can keep a short-lived copy of the session inside the cookie, so most requests validate without a database round-trip. That cache is on by default because it is faster, and I switched it off for the admin portal. With it on, deactivating an admin or forcing sign-out of their other sessions on a password change would keep being honored until the cached copy expired, because requests in flight still trust it. With it off, every admin request revalidates against the database, so a revoked session stops working on the very next request. At this traffic the extra query per request is invisible. Instant revocation for the accounts that can decrypt SSNs is not.
Running the delivery
I directed this across six cross-functional teams (planning, product, design, web, engineering, and QA) on a milestone plan that ran from wireframes and core backend through the five-step enrollment form, the submission pipeline, the self-service pricing engine, and the customer and admin dashboards, into QA and launch, with a rebrand and domain migration after go-live. Nine milestones, M0 through M9.
Two things living in one flow could lose the client’s trust and never earn it back: the PII and the money. Payments run through Stripe using Elements, subscriptions, and webhook-driven reconciliation, so an enrollment’s status follows what actually settled at Stripe rather than what the browser assumed happened. Duplicate-submission detection and explicit payment-failure handling cover the ordinary panic cases, a nervous applicant clicking twice or a card declined mid-flow. The gate before launch was a security review across SQL injection, XSS, CSRF, and auth-bypass, eight core end-to-end scenarios, and WCAG 2.1 AA. Everything shipped through CI/CD. After go-live came a 30-day support window and a clean, client-owned handover of the code, the credentials, and the documentation.
Outcomes
- <15 min
- <3s
- AES-GCM
- 9
- ~80%
- 10×