Security
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.
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’s memory while you’re logged in.
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 filenames of your records (they’re in the encrypted metadata).
- The recipients of your share links (the decryption key never reaches us).
- Your recovery code.
We can see
- Your email address.
- The structured findings extracted from each document — value, unit, date, code, reference range, conditions, medications, allergies, immunizations, recommendations, and a per-document narrative digest (imaging findings, raw impressions, AI-suggested questions for your doctor). We need these in the clear to power trend charts, the chart review, and search.
- Your demographic profile if you filled out the welcome wizard or Profile page: display name, birth year/month, sex assigned at birth, height, race/ethnicity (if shared), smoking / alcohol / pregnancy status, units preference. These help the AI interpret reference ranges and trends.
- Your profile photo if you uploaded one. This is the one 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).
- Plaintext document metadata extracted by the AI: document date, document kind (“lab report”, “MRI brain”), provider name, facility name, specialty. Used to filter and label your records list.
- File sizes, MIME types, upload timestamps, and a per-user HMAC of plaintext bytes (used to detect when you re-upload the same file).
- AI-derived chart reviews and themed insight clusters once you run them. These are stored in the clear in our database.
- 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, and the plaintext of your extracted chart during a chart review — both detailed under The plaintext moments below.
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 pepperkeys two server-stored values: your authentication verifier and the lookup token for share links. A database leak alone is not enough to log in as you or open your share links, because the attacker would also need the pepper that lives in our server config.
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.
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 twoplaces in the product where decrypted clinical data is sent to our AI provider (Anthropic). We document both because pretending otherwise would be a lie.
1. Per-document extraction
- After upload, your browser still holds the plaintext bytes in memory.
- 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 — with the trade-off that Anthropic retains batch inputs and outputs for up to 29 days on their infrastructure for retrieval. 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.
2. Chart-level AI review
When you run a Chart Review (Follow-ups page), we send your extracted chart — every record’s metadata and digest summary, every observation, every recommendation, your demographic profile — to Anthropic in one Batch API call. 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, same 29-day retention posture as per-document extraction.
Subscribers on the Plus tier instead use Anthropic’s synchronous Messages API for chart review. That path is faster (~30 seconds) and runs against Anthropic’s standard retention policy (configurable zero data retention at the account level).
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. Both features are user-initiated — you choose what to upload and when to run a chart review. 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.
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 (encrypted blobs unusable, password verifier peppered).
- Compromise of our object storage alone (ciphertext only).
- 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.
- Government compulsion of Anthropic during the 29-day batch-retention window for documents you extracted or chart reviews you ran. Plus tier users on the synchronous path narrow but don’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, the structured findings table (codes, values, units, dates), metadata about uploads and shares, the audit log, and the encrypted blobs themselves. The blobs would be useless without your master key, which we cannot produce because we don’t have it. 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 remain unreadable without your master key.
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. We currently use'unsafe-inline'for inline hydration scripts as a known pre-launch placeholder; the public-launch milestone replaces this with per-request nonces. - 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 in our changelog when they consent.
Want the source-of-truth?
The cryptographic code is small, self-contained, and lives in src/lib/crypto/. We’d rather you read it than take our word for any of the above.