How the vault works, and where it stops.
Written for the person deciding whether to trust this: a reviewer, a clinician, a journalist, or someone who reads response headers. It says what the design protects, and it is plain about what it does not.
Current as of 3 September 2026. Every claim here is about the code as deployed today, never the code we intend to ship.
Why it has to be built this way.
Intrusive thoughts are the content people are most ashamed of. Harm, sexual, blasphemous. If you believe an operator can read the transcript, you withhold, and a tool you lie to is worthless.
So the encryption is not there for a compliance checklist. It exists so the honest sentence can be typed.
What is sealed.
Everything that could carry what you actually said.
| Record | What it holds |
|---|---|
| Every message | The text itself, yours and the coach's. |
| Each conversation's title and register | One sealed blob. Which register a conversation ran in is a clinical fact, so it never sits in a readable column. |
| The coach's memory of you | The profile it keeps between conversations. |
| Your questionnaire answers and lesson notes | What you answered at the start, which lessons covered what, and what the coach has taught you. |
| Your practice ladders | Every plan, every step and every outcome, sealed whole. |
All of it is AES-256-GCM ciphertext, sealed in your browser before it is sent. The server never holds a key.
One test runs a whole conversation, then searches every collection in the database for a phrase that was in it. It fails if it finds one. It is the only test that checks the promise itself rather than a stand-in for it.
Signing in and unlocking are different doors.
Signing in proves you own an email address. A link is sent, you click it, and no account password is held anywhere. Losing a session costs you a fresh link and nothing else.
Unlocking the vault is a separate step with separate secrets. A random data key is generated once in your browser, never derived from anything, and never changed. Each way of unlocking wraps that same key independently.
data key random AES-256, generated once, never derived, never changes wrapped by: passkey HKDF(the PRF output your authenticator computes) passphrase HKDF(Argon2id(passphrase, salt)) recovery code HKDF(Argon2id(code, salt)) trusted device a random key the browser holds and cannot export
A passkey means Face ID, Touch ID or a security key, through the WebAuthn PRF extension: the authenticator computes 32 bytes from a secret it never hands over, and only after you have proved you are present. The passphrase and the recovery code are stretched with Argon2id before they become keys, and your browser refuses parameters below a compiled-in floor, so a server cannot hand back weak ones to make guessing cheap.
Because the data key never changes, changing a passphrase rewraps one small blob and touches no message. Two unlock methods is the minimum, and the server refuses a removal that would leave fewer, so a lost phone can never be the only way in. A trusted device never counts towards the two: it lives on one machine and dies with it.
Your vault is lost only if every method is lost at once. That is inherent to our holding no key, and it cannot be engineered away without our holding one. What you keep in that case is the account you pay for: a reset deletes the sealed records and every wrap, keeps your subscription and lesson progress, and starts a fresh vault.
Where the key lives.
In a key object the browser will not export, inside a dedicated Web Worker. Never in session storage, never in application state.
Be precise about what that buys: it prevents the key being stolen, and it does not prevent the key being used. A script injected into the page cannot read the key out, but it can ask the worker to decrypt while the tab is open. That turns a one-shot theft of a key that works forever into an oracle that works only while you are there, and only if the page’s policy lets it send anything out. It does not.
Some browser extensions patch workers and break them. When that happens the same code runs on the main thread instead, the key is still unexportable, and what is lost is the worker boundary that made a stray reference harder to reach. The worker path is always tried first.
What ties a message to its place.
Every ciphertext carries, bound into it, the version, your user id, the conversation, the message’s position, who said it, and which key version sealed it. Without that, a server could reorder, replay or graft messages between conversations and your browser would decrypt them happily.
The wraps use the same construction rather than the standard key-wrap mode, because the standard cannot carry that binding and so cannot stop a rollback to a superseded wrap.
Plaintext is padded to 256-byte buckets before sealing. The length of a message, next to its timestamp, is a meaningful signal about a mental-health transcript.
The one place your words are readable.
To write a reply, the model has to read your message. Your message and the recent conversation are sent to Anthropic unencrypted. Everything below narrows that window rather than pretending it is not there.
The model is Claude Sonnet 5, deliberately. It is the current model on which Anthropic offers zero data retention: inputs and outputs are not kept after the request and are not trained on, under our zero-data-retention arrangement. Anthropic retains prompts to its Fable and Mythos models for thirty days for safety work, so switching to one of those would be a privacy-policy change, and we would tell you first.
The chat route logs nothing and caches nothing. It reads metadata, the counts and timestamps and the ceilings you set, to enforce those ceilings before relaying, and it writes exactly one thing afterwards: the token counts the provider returned, added to the month’s total. Writing to the console is a lint error on that path, because the host captures standard output into logs.
The agent framework runs with its memory off and its telemetry off. It switches on remote export the moment it sees its own cloud keys in the environment, so production refuses to boot if they are present, and the deployment pipeline checks the environment for them before a deploy. A separate test runs the chat route under a stand-in model and fails if any request reaches a host outside Anthropic.
What we can still see.
Used for billing and abuse prevention, and listed in full.
- Your sign-in email address.
- When you sign in, and a keyed hash of the network address it came from.
- How many messages you send, and exactly when.
- The length of each sealed message, rounded up to the nearest 256 bytes.
- Daily token totals and a monthly cost, with no conversation attached to either.
- Your subscription state.
- The five ceilings you chose in Limits, and any pending change to them.
- Which lessons you finished and which fixed quiz option you picked. Never free text.
Timestamps are the strongest residual signal. Long sessions in the small hours, growing week on week, is clinically meaningful and readable without a word of plaintext. It cannot be removed while offering sync across your devices, so it is named here rather than hidden.
Some things are missing from the database on purpose, each of which looked like harmless metadata. There is no readable field for which register a conversation ran in. The usage ledgers carry no conversation id, because per-conversation traces rebuild a behavioural graph. The safety counters carry no user id at all. Email and network addresses are stored only as keyed hashes.
An administrator can look an account up by email and see the metadata above, resend a sign-in link, refund a subscription and sign an account out everywhere. Each of those is logged with who did it and when. An administrator cannot read a conversation, for the same reason nobody else can. The exact fields that screen may return are pinned by a test, so a new one has to be argued in.
Why there is no crisis escalation.
There is no human escalation and no per-user crisis flag. That is a product decision with a clinical reason as well as a privacy one.
Harm-themed intrusive thoughts are among the most common shapes this loop takes. They are the opposite of what the person wants, they do not indicate risk, and practice deliberately brings them up. A classifier tuned for self-harm would fire on that practice constantly. Worse, showing a crisis card every time such a thought appears teaches that the thought is dangerous, which is the belief the practice exists to wear down.
Three layers stand instead, none of which reads anything. A Get help now control is on every screen, never conditional, rendered from data bundled into the page. The coach can offer the same numbers through a tool call that carries a category and no free text. A prefilter in the browser catches means-and-plan language when the model misses, and is never transmitted. What is counted is a global daily total with no user id.
The consequence, stated plainly: we cannot reach out to someone in crisis. Onboarding says so, and the safety page carries the lines that answer at any hour.
Threat model.
| Threat | Protected | Not protected |
|---|---|---|
| Someone with access to the database | Transcripts, titles, memory and plans. Reordering, replaying and grafting are blocked by the binding described above. | Email, sign-in times, hashed addresses, counts, timestamps and token totals. |
| A stolen database backup | Everything that carries content. A wrap can be attacked only through the passphrase, at Argon2id cost per guess. | The same metadata. A weak passphrase falls regardless, which is why the floor exists. |
| A compromised deployment, or malicious JavaScript served by us | Nothing. This is the floor, and the next section is about it. | Everything. |
| A subpoena | We can produce only ciphertext, metadata and billing records. | Metadata is producible and is itself sensitive. An Australian TOLA notice can compel building new capability, with non-disclosure attached. |
| Cross-site scripting | Key theft. The key cannot be exported from the browser. | Key use while the tab is open. Held down by a policy that lets no script send a byte anywhere but here. |
| A stolen session cookie | Your words. A session authenticates requests and yields no key material. The attacker downloads ciphertext and stops. | Metadata and account actions. The destructive ones ask you to sign in again. |
| The model provider | Nothing at inference, by construction. | The sent window, in plaintext. Contractual only: zero data retention, no training, and our choice of model. |
The real weak point.
Encryption delivered by a website is trusted on every load. Each time the page opens, the server has a fresh chance to ship different code, and code that holds your passphrase for a moment could keep it. A native app is signed once and reviewed by a store. A web app is not.
Subresource integrity does not fix this. Whoever can change the JavaScript can change the HTML that carries the hashes, and here both come from the same origin. It defends against a compromised asset host while the page itself stays honest, which is a different threat.
What actually helps, and where each control stands today. Any service claiming that browser-based encryption makes access impossible is not being straight with you, and the point of this table is that we are not claiming it either.
| Control | Today |
|---|---|
| Review required on the key-handling and API paths | A code-owners file is in place. The branch protection that makes it binding is not switched on yet, so until then it is advisory. |
| Reproducible builds with published bundle hashes | Not built. It is the strongest honest answer available to a web app, and it is on the list. |
| A minimal dependency surface on the crypto path | Two libraries, one for Argon2id and one for talking to the worker, pinned to exact versions and never updated automatically. |
| No third-party scripts where the vault is open | In place. Billing goes through a Stripe redirect, so Stripe's script never runs inside the app. Analytics run on the landing page only. |
| A content security policy on the app | In place. Scripts from our origin only, no remote images, no framing, no embeds, and no script may connect anywhere but here. There is no per-request nonce, which broke prerendered pages when we tried it, and no Trusted Types, so an injected inline script would run. What it could not do is send anything out. |
Model output is rendered as markdown with raw HTML disabled, and the policy allows no remote images. A prompt-injected image pointing at someone else’s server would be the most direct way to carry a conversation out, and it is the attack we expect to see first, so it is the one closed most tightly.
Abuse limits.
These are ours, and nobody is meant to meet one. The ceilings you set for yourself in Limits are a different thing: refusing one of those is part of the practice.
| Bucket | Limit | Why |
|---|---|---|
| Sign-in links per email address | 5 an hour | The send is unauthenticated and mails an address a stranger typed. |
| Sign-in links per network address | 20 an hour | The same attack sprayed across many addresses, which the first count cannot see. |
| Chat requests per person | 10 a minute | The only ceiling on the chat route that counts its own requests. |
The counters store a keyed hash of the subject and expire with the window they count, so they never become a record of when somebody used an OCD app. Every bucket fails open when the counter cannot be read: a limiter that is down must not become a locked front door on a mental-health app.
There is no server-side limit on unlocking a vault, and there cannot usefully be one. The wraps are fetched once and every attempt happens in your browser, so the server never learns that a guess failed. Anyone in a position to guess already has the wrap and is guessing offline, where the only defence is the cost of each Argon2id attempt. A count of failures the client chose to report would be a control in name only.
What you can check from outside.
Open your browser’s developer tools on any screen inside the app and read the Content-Security-Policy header. The line that matters is the one that lets scripts connect only to this origin. Then watch the network tab while you send a message: what leaves for our server is ciphertext, and nothing leaves for anywhere else.
The privacy page lists what we hold in the same words as this page, and the two change together.
What you cannot check yet, said plainly. The source is not public today, so the tests named on this page are our word. Published build hashes, which would let you compare the code you were served against a known build, are on the list and not built.
If you find something, email hello@usemaybe.app. The same address is published at /.well-known/security.txt. We would value criticism of the key hierarchy and the threat model most.