How we secure your record — and where we can’t.
This page describes our architecture in enough detail that an engineer can evaluate it. We avoid jargon where we can and define it where we can’t. If you find an error or have a responsible-disclosure report, email security@open-chart.com.
We call this BYOK, bring your own key: the key that unlocks your records comes from your password, in your browser, and we never hold it. The exceptions, and exactly what stays readable to us, are below.
The model in one paragraph
You sign up with a password. Your browser runs Argon2id over the password to derive a 256-bit key — your password-derived key. From that key we expand two subkeys: one that wraps your master encryption key, and one that authenticates you to our servers. Neither the password nor the wrapping key ever leaves your device. Your master key wraps a fresh per-file key for every document you upload, and that per-file key encrypts the document with AES-256-GCM before it reaches our storage. What we store is wrapped key material plus an encrypted blob — neither is useful without the master key, which lives only in your browser, in this tab’s session storage, while you’re logged in.
The structured rows we derive from those documents — the lab values, the conditions, the medications — are encrypted the same way, under a key wrapped to an RSA public key your account holds. That second keypair exists so our server can seal a row it never gets to read: encrypting needs only the public half, and the private half is itself sealed under your master key. It is what makes importing from a health system possible without us holding the result in the clear.
What we can see, in plain English
We cannot see
- Your password.
- Your master encryption key.
- The contents of any source document you’ve uploaded — once it’s in storage, we only hold ciphertext.
- The clinical content of your chart. The name and value of every result, which condition you have, which drug at what dose, which allergy, which vaccine, every per-document digest, and every AI insight. These sit in ordinary database columns, but the columns hold ciphertext sealed under your key.
- The records we import from a health system — including the original FHIR the health system sent. Our server encrypts each one on arrival and keeps only a copy of the key wrapped to your public half.
- The health-bearing fields of your demographic profile: birth year and month, sex assigned at birth, height, race, ethnicity, countries lived in, and your smoking, alcohol, and pregnancy status.
- The filenames of your records (they’re in the encrypted metadata).
- The kind, provider, facility, and specialty of each document — those are extracted, then sealed with everything else.
- The recipients of your share links (the decryption key never reaches us).
- The contents of your saved chat conversations — each message is encrypted under your master key before it reaches us, so the stored history is ciphertext.
- Your recovery code.
We can see
- Your email address and your display name.
- The shape around the clinical content. Every clinical row keeps a readable date, category, status, severity or priority, and source, plus row counts and foreign keys. So we know when your care happened, how often, how much of it there is, and the broad category it falls in — that a lab panel was drawn on a given date, that a condition is still active. Never what was measured, prescribed, diagnosed, or written. See The clinical index below for why these columns stay readable.
- Your profile photo if you uploaded one. This is the largest deliberate exception to the BYOK posture — it lives as a plaintext blob so we can render it for you without a client-side decryption step. Share-link recipients also see your photo and display name (to help a doctor remember whose records they’re looking at).
- The date of each document. It stays readable because sorting and paginating your records depends on it —
ORDER BYover ciphertext returns an arbitrary page, which would drop points off a trend chart with no error at all. - File sizes, MIME hints, upload timestamps, and a per-user HMAC of plaintext bytes (used to detect when you re-upload the same file).
- Which health systems you have connected, your patient id at each, which permissions each granted, and when every sync ran and whether it worked. Also the OAuth token for that connection — encrypted, but under a key we hold. See Records imported from a health system below.
- Which insights and suggested questions you have dismissed. Stored as keyed HMACs under a server-side secret so a dismissed item does not resurface.
- Subscription status, plan tier, and an opaque Stripe customer ID if you’ve upgraded. We do not store your card data, billing address, or name on card — those live with Stripe.
- Audit metadata: when you signed in, what records you uploaded or downloaded, when share links were opened, a hashed indicator of your IP.
- The plaintext of a document during AI extraction, the plaintext of your extracted chart during a chart review, and your questions plus chart context while the assistant answers a chat — all detailed under The plaintext moments below. (Saved chat history is then encrypted under your master key; we don’t retain the plaintext.)
Key derivation and wrapping
The cryptographic primitives we use are standard and well-reviewed. None are exotic.
- Argon2id turns your password into a 256-bit uniform secret. We default to 64 MiB of memory, 3 iterations, single-lane parallelism — tuned to spend roughly half a second on a 2024-era laptop. Parameters are stored per account so we can raise them over time without invalidating old accounts.
- HKDF-SHA-256 expands the password-derived key into two domain-separated subkeys: the key encryption key (KEK) that wraps your master key, and the authentication verifier we send to the server. Different HKDF info strings ensure the two subkeys can’t be confused, even with identical inputs.
- AES-256-GCM is the authenticated encryption we use for every key wrap and every record blob. Each call uses a freshly random 12-byte IV. We never reuse a (key, IV) pair.
- HMAC-SHA-256 with a server-side pepper keys your authentication verifier, the lookup token for share links, and the record of which insights and questions you have dismissed. A database leak alone is not enough to log in as you, open your share links, or reconstruct a dismissal, because the attacker would also need the pepper that lives in our server config.
- RSA-OAEP-2048 is the one asymmetric piece, and it exists for imports. Your account has a keypair; the public half is enough to encrypt a record, so our server can seal an imported record without ever holding the half that opens it. The private half is itself wrapped under your master key.
Per-document encryption
Every record you upload gets its own randomly generated 256-bit data encryption key (DEK). The DEK encrypts the document with AES-256-GCM. We then encrypt the DEK with your master key — also AES-256-GCM — and store the wrapped DEK in our database alongside metadata. To decrypt the document, your browser fetches the wrapped DEK and the ciphertext, unwraps the DEK with your master key, and decrypts the document locally.
This design has a useful property: sharing a record never requires us to re-encrypt the file. When you create a share link, your browser unwraps the DEK with your master key, then re-wraps the same DEK under a key derived from a random 256-bit secret embedded in the share URL. We store the rewrapped DEK; the URL secret never touches our servers.
The clinical index
A record is not much use as a blob. To draw a trend chart, or filter your chart by date, the values inside a document have to live in database columns. That used to mean our server could read them. It no longer does.
Each batch of extracted or imported rows gets a fresh 256-bit DEK. Every clinical value in the row is encrypted with it under AES-256-GCM, the DEK is wrapped to your account’s public key, and the server zeroes its copy after writing. Opening a row needs the private half, which is wrapped under your master key and only ever unwrapped in your browser. There is no production server-side decrypt path: the only function that can open one of these rows requires a key we never receive.
Some columns stay readable, deliberately. Ids and foreign keys, because the database needs them. Dates, because ORDER BY effectiveDate DESC over ciphertext returns an arbitrary page rather than the most recent — a chart would quietly lose points with no error at all. And low-cardinality enums like category, status, severity and priority, because encrypting a four-value column is theatre under frequency analysis.
What that gives away, stated plainly: a reader of our database learns when your care happened, how often, how much of it there is, and the broad category it falls in. Not what was measured, prescribed, diagnosed, or written. We hold that line with a test rather than with vigilance — the sealed column list is pinned against the database schema in CI, so a new column fails the build until someone decides which side it belongs on.
What it cost. Anything that used to read your chart on our server now needs your browser to decrypt and send what it needs — chart review and the assistant both work that way. The read-only FHIR endpoints on the Plus tier cannot serve sealed rows at all, and return an explicit error saying so rather than a bundle that quietly omits them: a receiving system has no way to tell an incomplete chart from a sparse one, and would draw conclusions from the gap. A browser-mediated export, which decrypts in your own session, is the replacement.
Records imported from a health system
open-chart can connect to a health system over SMART on FHIR and pull your records directly, instead of you uploading files. You sign in on your health system’s own patient portal — we never see that password — and it returns an authorization scoped to a single patient. We do not get to choose whose record we receive.
Every permission we ask for is a read permission. Results and vitals, conditions, medications, allergies, immunizations, and the patient record behind the identity check below. open-chart has no ability to write into your medical record at the health system, and does not ask for one.
The import is encrypted before it is stored, or it is not stored at all. That is unconditional rather than best-effort: the import reads your account’s public key up front and refuses to run when there is none. For each record the server mints a fresh DEK, encrypts the original FHIR the health system sent, wraps the DEK to your public key, and zeroes its own copy. It writes down a record it can never open. The structured rows derived from that record go through the clinical index above and are sealed in the same pass — they have to be, because once the source row is sealed no later job could read it.
The refresh token is the exception, and the key is ours. Keeping one is what spares you a trip back through your health system’s login every time you import, and a token refresh happens with your master key nowhere nearby — a refresh token we cannot read is a refresh token we cannot use. The OAuth credentials for a connection are therefore encrypted under a key held in our server environment rather than under yours. The consequence, stated straight: that key plus our database would let someone fetch your records from the health system directly, and our staff are the people who hold it. It would not open a single record already imported — those are sealed to you. The key protects access tokens only and is never used for content.
We do use it, on a schedule, and here is exactly how. Once you have confirmed a connection is yours, open-chart checks that health system about once a day for new records and brings them in while you are away. Everything it fetches is sealed on arrival to the public half of your key — the same envelope your own imports use — so a record lands unreadable to us and waits for the next time you sign in. We email you that something arrived, never what it was. The server-held key above is what makes this possible: it opens the refresh token and nothing else. It cannot open a record, before the sync or after it.
The honest consequence is that we are not a service that merely could reach your health system and chooses not to. We are one that does, daily, on your behalf. Every application that connects this way and stores a refresh token holds the same power; the alternative is re-authorizing on your health system’s site every single time. What we can offer instead of a promise is that the job cannot read what it collects, that disconnecting deletes the credentials outright rather than flagging the row inactive, that the checks stop on their own if you stop signing in, and that you can revoke our access from your provider’s portal independently of anything we do.
The identity gate. A patient-portal session can be in proxy context — a parent signed in for a child, an adult child for a parent — and the token for that other person is indistinguishable from a token for yourself. So nothing imports until a human has looked at the name and date of birth behind the connection and confirmed it is theirs. The failure this prevents is silently merging someone else’s medical history into your chart, which has no visible symptom.
Disconnecting keeps your records; removing them is a separate step. Ending a connection leaves what it already imported in your chart, because those records are yours and dropping them because a link ended would be the wrong default. Remove these records on the Import records card is the other action: it deletes every row that connection created, stored source bundles included, in one transaction. A real delete, not a hidden flag. It exists because the identity gate above is one click wide and someone who answers it too fast needs a way back — and note that the purge needs no key at all. Our server cannot read one of those rows and can still remove every one of them.
Two smaller controls worth naming. A FHIR bundle carries the URL of its own next page, and we attach an access token to every page request, so we follow a paging link only when its host matches the FHIR endpoint we started from — server-supplied content does not get to redirect our token elsewhere. And a connection takes a lock for the duration of an import, so two imports of the same connection cannot crawl the same organization at once — which matters because a health system may rotate the refresh token, and the loser of that race would write back one that has already been revoked.
Sharing via URL fragment
When you share a record, your browser generates a 256-bit random secret, derives an encryption key from it, and rewraps the record’s DEK under that key. We store the rewrapped DEK and an HMAC of the URL secret (so we can find the right grant when a recipient opens the link) but never the secret itself. The full share URL looks like:
https://open-chart.com/share/<grant-id>#<secret-256-bits>
The portion after # is the URL fragment. Web browsers never send fragments to servers in ordinary navigation — they exist only on the recipient’s device. The recipient’s browser reads the fragment, asks our server for the grant, decrypts the record locally, and (importantly) scrubs the fragment from its history immediately afterward so it doesn’t live in the back/forward stack.
Each share has a configurable expiry, a maximum number of uses (default: one), and can be revoked from your dashboard at any time. The atomic conditional update we use on the server prevents two recipients from racing past the maxUses limit. Share-link recipients also see your display name and profile photo if you set them — designed to help a clinician remember whose records they’re looking at when they’re juggling multiple patient links.
The plaintext moments
Reading structured findings out of your records requires running an LLM over the content. There are four places in the product where decrypted clinical data is sent to our AI provider (Anthropic). We document all four because pretending otherwise would be a lie.
1. Per-document extraction
- After upload, your browser still holds the plaintext bytes in memory. For a note or report imported from your health system, nothing is sent until you press Analyze on it; your browser then decrypts it with your key, and the steps below are the same.
- It POSTs those bytes to our extraction endpoint as multipart form data.
- The endpoint submits them to Anthropic’s Batch API with a structured-output tool definition. The Batch API is roughly 50% cheaper than the synchronous Messages API — we pass the savings to you. They do not train on this data.
- The endpoint receives a batch id back inside a second; the browser buffer is zeroed. A background worker on our side polls for completion (typically a few minutes; SLA up to 24 hours), parses the result, and writes Observation rows plus other structured tables to our database.
- The moment the result is stored, we delete the batch from Anthropic. The Batch API would otherwise keep inputs and outputs retrievable for weeks; deleting on completion cuts that to roughly the processing time — minutes. (Any residual retention after deletion is governed by Anthropic’s own data policy.)
2. Chart-level AI review
When you run a Chart Review (AI Review page), your extracted chart — every record’s metadata and digest summary, every observation, every recommendation, your demographic profile — goes to Anthropic in one Batch API call. Since those rows became ciphertext, our server can no longer assemble that chart itself: your browser decrypts it and posts it with the request, and the server builds the prompt from what it was handed and discards it. A review that silently analysed only the rows we could still read would be worse than no review, because it would look complete. No image bytes leave us during this step. The model synthesizes the chart into a short list of themed insights (stale recommendations, abnormal trends, care gaps) and clusters of related items. Same Batch API, and — as with extraction — we delete the batch from Anthropic the moment the result is stored, so it isn’t left sitting in their batch store.
Subscribers on the Plus tier instead use Anthropic’s synchronous Messages API for chart review. That path returns results while you wait and runs against Anthropic’s standard retention policy (configurable zero data retention at the account level).
3. Ask your record (chat)
When you ask the in-app assistant a question, your extracted chart context (the same structured findings, digests, and demographics used by chart review) goes to Anthropic’s synchronous Messages API along with the messages in that conversation, to generate the answer. As with chart review, your browser is what decrypts that context and posts it — our server cannot assemble it on its own. No image bytes leave us. This path runs against Anthropic’s standard retention policy (configurable zero data retention at the account level). They do not train on this data.
The conversation itself is then saved the same way your documents are: each message is encrypted in your browser under your master key (AES-256-GCM) before it reaches us, so your saved chat history is ciphertext we cannot read. We store only the ciphertext and the message order; we never persist the plaintext of a conversation.
4. Doctor packets
When you draft or revise a doctor packet, or ask it to rate which documents to attach, your browser decrypts your chart context and posts it, and our server sends it to Anthropic’s synchronous Messages API to write the draft. The draft comes back to your browser and is not stored on our servers. What we store is only the packet you publish, encrypted in your browser, with its key carried in the share link’s URL fragment. This path runs against Anthropic’s standard retention policy (configurable zero data retention at the account level). They do not train on this data.
Boundaries
Plaintext is never written to our disk, never logged on our side, and is discarded when the request handler returns. The retention sits on Anthropic’s side, not ours. All four features are user-initiated — you choose what to upload, when to run a chart review, and what to ask. If this trade-off is not acceptable for your threat model, the rest of the product still works as encrypted document storage with share links.
One case stretches “the request handler returns” and we would rather say so than let you find it. If you start a chart review while documents are still processing, the decrypted context your browser sent is held in our server’s memory until the job actually runs — bounded, single-use, expiring, and never written to a database, a log, or a disk.
Imports are not one of these moments. A record pulled from a health system arrives already structured, which is the whole reason importing beats parsing a PDF: there is no extraction step and no AI provider involved. Nothing imported goes to Anthropic unless you separately run a chart review, ask the assistant a question, or draft a doctor packet.
One more plaintext moment is worth naming even though no third party is in it. During an import our server necessarily holds the FHIR the health system sent — for the moment it takes to encrypt it and derive the clinical index from it. That plaintext goes nowhere, is never written down in that form, and is gone when the import finishes. It is also the last time anyone but you can read those records.
Recovery
A user-derived key system has one painful failure mode: if you forget your password and we can’t see it, the data is gone. Our mitigation is the recovery code we show you at signup. It’s a 160-bit random value, displayed as a 32-character Crockford base-32 string. Mechanically, it wraps a second copy of your master key.
When you redeem a recovery code we never receive the code itself. Your browser uses it to unwrap the master key locally, re-wraps the master key under a new password you choose, and proves possession of the code to the server with a separate HKDF-derived authentication value. After a successful reset we rotate the recovery code (the old one is retired) and revoke every existing session so a stolen old-password device is kicked off.
The recovery code is the most sensitive credential in the system. Print it. Save it in a password manager. Anyone who has it can reset your account.
Threat model
What we defend against
- Compromise of our database alone — the record blobs are ciphertext, the clinical values inside every row are ciphertext, and the password verifier is peppered. What an attacker gets is dates, categories and counts.
- Compromise of our object storage alone (ciphertext only).
- A rogue insider reading your chart out of the database. The values are sealed to a key we do not hold, and that includes records imported from a health system.
- Network adversary intercepting traffic (TLS in transit).
- Casual social-engineering of customer-support staff (we don’t hold your keys).
- Recipients of share links leaking the URL (single use, expiry, revocation).
What we can't fully defend against
- A compromise of your endpoint — malicious browser extensions, keyloggers, or device theft after you’ve logged in.
- An adversary holding both our database and our server pepper at the same time, who could mount an offline attack on weak passwords.
- An adversary — our own staff included — holding both our database and the key in our server environment that protects connection credentials. That combination opens the refresh token for a connected health system and lets them pull from that health system directly. It does not open anything already imported. Our own daily check uses that key for exactly this purpose, which is the point rather than a caveat: the capability is real and in use, not dormant, and the only honest mitigations are that what it fetches is sealed to you on arrival and that disconnecting deletes the credentials outright.
- Government compulsion of Anthropic in the narrow window before we delete a batch, or for the questions and chart context behind a chat (synchronous path, Anthropic’s standard retention). We delete extraction/chart-review batches on completion, which shrinks but doesn’t eliminate this exposure.
- A bad-faith recipient of a share link who screenshots or copies the decrypted record.
- Forensic recovery from a backup we’ve since deleted from a sub-processor we no longer use.
What we’d disclose under legal compulsion
If a court order required us to produce everything we hold about you, the response would include: your email and display name, your profile photo, the date, category and status on every clinical row and how many of them there are, metadata about uploads and shares, which health systems you have connected, the audit log, and the ciphertext — the record blobs and the sealed clinical values alike. So the response would establish that you had care, when, how often, and of what broad kind. It would not establish what was measured, prescribed, diagnosed, or written, because opening those needs your master key and we cannot produce a key we don’t have.
One part we could be compelled to act on rather than hand over: the credentials for a connected health system are encrypted under a key we hold, so an order could in principle reach the records still sitting at that health system. Disconnecting destroys those credentials. We commit to publishing a transparency report describing the count and nature of any such requests once we receive a non-zero number of them.
What we’d do in a breach
If we discover an incident affecting your account, we will: (1) tell you within seven days of confirming the scope; (2) revoke any in-flight share links and sessions if there’s any chance they were exposed; (3) publish a post-mortem describing what happened, what data was affected, and what we’ve changed. The encryption design is built so that even in a worst-case loss of our database and object storage, your source documents and the clinical values derived from them remain unreadable without your master key. The one thing we would tell you to act on is your provider connections: disconnect them, and revoke open-chart in your provider’s portal.
Operational hygiene
- Sessions are stored as 32-byte random tokens with the SHA-256 HMAC of the token in the database — the raw token is only in your cookie.
- Session cookies are
HttpOnly,Secure(in production),SameSite=Lax. - Content-Security-Policy locks
script-src,connect-src,frame-ancestors, andform-action. In productionscript-srcuses a per-request nonce (no'unsafe-inline'); the permissive policy is kept only in local development, where a nonce CSP breaks hot-reload. - All authentication endpoints constant-time compare; failed logins are audited.
- Recovery code rotation invalidates all existing sessions.
- Dependencies are pinned and reviewed; we run automated audit checks before each deploy.
Responsible disclosure
If you find a vulnerability, please email security@open-chart.com with a description and (if possible) a proof of concept. We will respond within three business days. We do not run a paid bounty program yet, but we credit researchers on this page when they consent.
Want the source-of-truth?
The cryptographic primitives are small and self-contained, and live in src/lib/crypto/ (AES-256-GCM, Argon2id + HKDF, and the key-wrapping above); the server-side hashing and peppering are in src/lib/server/hash.ts. We’d rather you read it than take our word for any of the above.