End-to-end encrypting a health-records web app
A technical walk-through: Argon2id and HKDF, a wrapped data key, AAD-bound records, passkey unlock with WebAuthn PRF — and a live demo of an encrypted account that can't be changed.
SECURECORDS is a tracker for blood test results and vitals. The design goal was simple to state and harder to build: the server must never see a single value — and must never hold anything that could decrypt one — while the product still works like an ordinary web app: sign in on any device, charts, PDF reports, plan limits, even a public live demo. This is how it fits together, including the parts we're not fully happy with.
The stack is .NET 10 with Blazor WebAssembly on the client and PostgreSQL on the server. All cryptography runs in the browser, in one small JavaScript module that uses the Web Crypto API.
Keys: one password, two unrelated outputs
A user's password never leaves the browser. It's stretched with Argon2id (64 MiB, 3 passes, per-user random salt), then HKDF-SHA256 splits the result into two keys:
password ── Argon2id(salt, m=64 MiB, t=3, p=1) ──► master
master ── HKDF "…:auth" ──► authKey → sent to the server, stored only as a salted PBKDF2 hash
master ── HKDF "…:kek" ──► passwordKEK → never leaves the browser
The authKey is what signs you in. Because it comes out of a different HKDF branch, knowing it reveals nothing about the passwordKEK, which is what unlocks your data. The server can verify who you are without ever being able to derive a decryption key. (It's the same idea Bitwarden uses.) Argon2id runs as WebAssembly from a vendored, self-hosted copy of hash-wasm — there's no CDN anywhere.
One data key, wrapped several ways
Each account has a random 256-bit data key (DEK), generated in the browser at sign-up. Records are encrypted with the DEK; the DEK itself is stored on the server only in wrapped (encrypted) form:
DEK (32 random bytes, created in the browser)
AES-256-GCM(passwordKEK, DEK) → wrapped_dek_password (server)
AES-256-GCM(recoveryKEK, DEK) → wrapped_dek_recovery (server)
AES-256-GCM(passkeyKEK, DEK) → one per passkey (server, optional)
Signing in on a new device is: fetch the KDF parameters, derive the authKey, prove it, receive wrapped_dek_password, unwrap it locally. Changing your password re-wraps the same DEK with a new KEK — no records are re-encrypted. Once unwrapped, the DEK is imported as a non-extractable CryptoKey and kept in memory only. Nothing key-related ever goes to localStorage, which is why a reload asks you to unlock again — a deliberate trade-off.
Recovery without an email reset
An email "reset password" link would let anyone who takes over your inbox take over your records — so there isn't one. At sign-up the browser creates a separate 256-bit recovery key, shown once in Crockford base32 with a short checksum so typos are caught before any network request. Like the password, it splits into a recovery authenticator and a recoveryKEK. Using it lets you set a new password, and it's then replaced — a used recovery key never works twice.
The honest consequence: lose both the password and the recovery key, and the data is gone. We say so plainly at sign-up.
Every record is bound to its row
Each record — a reading, a tracked marker, a life event, a family profile — is encrypted with AES-256-GCM using a fresh random 96-bit IV, and additional authenticated data that names the record type and its IDs:
reading → AES-GCM(DEK, json, aad = "securecords:v1:reading:{readingId}:{seriesId}")
envelope → version(1) ‖ iv(12) ‖ ciphertext ‖ tag(16)
Because the AAD is checked on decryption, the server can't swap one ciphertext into another row, attach a reading to a different marker, or move records between users without the client noticing. Even which test a row belongs to is inside the ciphertext: the server sees "a tracked marker, built-in or custom" — not "LDL cholesterol".
Plan limits on data you can't read
The Free, Plus and Pro plans have limits — 500 readings, a number of custom biomarkers, family profiles. The server enforces them by counting rows, which it can do without reading them: a reading is a row; a custom biomarker is a row flagged as custom; a family profile is a row. Which profile a record belongs to lives inside the ciphertext, so the server can count profiles without knowing whose data is whose.
Passkey unlock with the WebAuthn PRF extension
Typing a long password on a phone after every reload gets old. Passkeys help, but a passkey normally proves who you are — it doesn't give you a key. The PRF extension does: during a WebAuthn ceremony the authenticator returns a secret derived from the credential and a fixed salt, after Face ID, a fingerprint or a PIN.
PRF(credential, salt) ── HKDF "…:passkey-kek" ──► passkeyKEK
AES-GCM(passkeyKEK, DEK, aad = "…:dek:passkey:{credentialId}") → stored per device
So a passkey becomes one more way to unwrap the same DEK, bound to that credential. Two deliberate limits: passkeys only unlock — they never create a session, so signing in on a new device still needs the password — and they're never described as a way to recover a forgotten password.
A live demo of an encrypted account
We wanted visitors to try the product without signing up — not a mock-up, but a real encrypted account. The demo is one shared account with made-up data whose password is public. When you open it, your browser runs the normal sign-in: Argon2id, unwrap, decrypt. Two layers keep it intact:
- Server: a middleware refuses every write from the demo account — record saves, password and recovery-key changes, passkeys, billing, deletion — with a 403, whatever the client sends.
- Client: when the session says "demo", the API client simply skips record writes and the store keeps changes in memory. Visitors can log readings, add events and switch units; a reload brings the original back.
The server-side guard is the real guarantee; the client side just makes the demo pleasant. A published password is fine here because it only unlocks made-up data that can't be changed.
PDF import, without uploading the PDF
People want to import the PDF their lab gives them. Sending it to a server — or to an AI service — would break the whole model. Instead the browser reads the PDF's text with pdf.js (self-hosted, loaded on demand), and a parser in the app looks for the shape every report shares: a test name, a number, a unit, a range. It knows common names for each marker, US and SI units, decimal commas and ambiguous dates, and it remembers the names a user maps by hand inside their encrypted profile. Values only pre-fill a form, each shown with the line it came from; nothing is saved until the user confirms. Scanned PDFs have no text and get a plain "enter these by hand" — we chose not to do OCR.
The rest of the hardening
- Content-Security-Policy is enforced, with no inline scripts. Blazor's one inline element, its import map, gets a per-request nonce.
- One third-party script, carefully fenced. Cloudflare's bot check runs only on the sign-up email step and the contact form. Those pages are always entered and left with a full page load and have their own CSP, so third-party code never shares a document where a password is typed or a key exists.
- Sessions use an HttpOnly, SameSite=Strict, host-only cookie that's invalidated when the password changes.
- No analytics or trackers. Usage figures for the operator come from row counts and timestamps the server already has.
What we can see — and the limitations
End-to-end encryption doesn't hide everything. The server sees your email address, your plan and billing status, how many records you have and their sizes, when they were created or changed, whether each tracked marker is built-in or custom, how many profiles and lab-report imports an account has, and standard request logs. All of it is listed on our Security page and in the Privacy Policy.
And the limitations we'd rather state than have discovered:
- You're trusting the code we serve. Like every web app, the encryption is only as good as the JavaScript delivered to your browser. A malicious or compromised server could ship code that leaks keys. Native apps with reproducible builds, or code-signing for web apps, would narrow this.
- No third-party audit yet, and the code isn't open source.
- Metadata exists — counts, sizes and timestamps can reveal patterns, even if not contents.
- Lose both the password and the recovery key, and the data is gone. That's the cost of no backdoor.
Try it
The live demo runs the full flow in your browser, and the Security page has the details in plain language. We'd genuinely like feedback on the design — especially on what would make you trust it more. You can reach us through the contact form.