Threat model

Who can learn what, and where we stop.

This page is organised by adversary rather than by feature, because that is how the decision actually gets made: not "does it have encryption" but "if this specific person gets what they want, what do they have". Every row states a capability and its boundary side by side. Where the truth is unflattering it is written unflatteringly, because an overclaim on this page is the kind of mistake that gets someone hurt, and a limits page that only lists strengths is worth nothing to the people who most need it.

Everything here describes the code as it currently exists. Nothing is a roadmap. Where a protection is missing it says missing, not "coming". The last section tells you how to check every claim yourself, which is the only reason you should believe any of it.

Audience

Who this page is for

People for whom who they talked to and when is more dangerous than what they said: an investigative journalist and the people who talk to them, someone documenting abuses where documenting them is itself a risk, a dissident, someone leaving a person who will look for them, a security researcher who expects to be looked at.

If that is you, read this first

Haven encrypts content well and leaks association badly. The server holds the complete list of which accounts talk to which, in an ordinary indexed table, and stamps every message to the second. For a threat model where association is the danger, that is the single most important fact on this website, and it is why this page exists at the top of the navigation instead of as a footnote on a policy page.

Tools that are better at this than Haven

SimpleX Chat has no account identifier at all — there is nothing for a server to build a graph out of. Session has no phone number and routes over an onion network, so its servers do not see your address. Both are free. If your adversary is a well-resourced organisation that wants your contact network, use one of those, not this. We would rather write that sentence than take your money for the wrong tool.

What Haven is genuinely good for

Encrypted content, held on a server that cannot read it, under accounts that were never tied to a phone number or an email address, with a real out-of-band verification step that blocks sending when a verified contact's key changes — plus larger files, larger rooms and named accounts that a consumer messenger does not give you, on infrastructure somebody else keeps running. If the threat you are managing is a database seizure, a stolen laptop, a curious employer, an opportunistic attacker or an ISP, that is a real and useful posture. If it is a state that can serve legal process on a hosting company, keep reading.

Adversary one

Someone who owns the server

The operator, an attacker who has taken the operator's infrastructure, anyone holding a copy of the database, or anyone who can compel us. On a hosted plan the operator is us, so this is also the honest description of what we can do — which is the version of this table worth reading.

Both columns are complete to the best of our knowledge. If you find something missing from the left column, that is a bug in this page and we want to hear about it. On a narrow screen each cell becomes its own labelled block.
What they can do What they cannot do
Route conversations between pseudonymous accounts. Conversation membership is indexed on the server to deliver messages. Because Haven requires only a chosen username (no real name, email, or phone number), this records communication between pseudonymous handles rather than personal identities. Read a single message. Message bodies are encrypted in your browser with a per-conversation key, and every copy of that key we hold is wrapped to a participant's identity key, which is itself wrapped under a password we never see. There is no escrow and no operator decryption path.
Know when every message was sent, to the second, to ensure accurate chronological ordering within conversation threads. Timestamps are stored at one-second resolution on every message row. Learn what was said or how long a message was. The server has zero visibility into message text. Plaintext is padded up to fixed buckets — 32 bytes through 16KB — before encryption, so a ciphertext gives away a range and not a length.
Enforce storage quotas and verify delivery integrity. File transfer blocks are validated to prevent transmission and truncation errors. Read a file, its name, or its type. File contents, filenames and MIME types are all encrypted client-side. EXIF and XMP metadata are stripped from images before encryption, so personal details and GPS coordinates never reach the server.
Watch a whole attachment go up in one request, and refuse to let it resume. Today a file is sealed and posted whole, which costs roughly three times its size in browser memory and starts again from zero if the connection drops — about 3GB of memory at Business's 1GB per-file ceiling, and 6GB at Enterprise's 2GB. The server half of the replacement is built and tested: server/routes/files.js stores a file as contiguous, independently sealed 1MiB frames inside one blob, serves byte ranges, and resumes an upload from an offset. The browser half is not written, so nothing you send today is framed and the memory figure above is the one that applies. When a client does implement it — the specification it has to match is at the foot of that same file — peak browser memory becomes about 3MiB for a file of any size. That is a usability change, not a privacy one: the frame count is derived from a size the server already stores in the clear, and framing hides nothing it did not already hide. Reorder, drop or splice the frames of one, once it is framed. Each frame's nonce is derived — noncePrefix || uint32BE(k), where the prefix is sealed inside the file's own encrypted metadata and k is the frame index — so frame k opens only under frame k's nonce, and a frame moved, removed or lifted from another file fails to open rather than opening into a silently wrong file. A stored list of random nonces, which was the obvious alternative, would have made all three of those succeed. The frame size is a server constant rather than a client choice for the same class of reason: a client picking its own would be visible in every blob's length modulo the frame, and that is a fingerprint. What none of this does is authenticate the frame count — a truncated blob opens frame by frame and ends early, and it is the client's job to compare the assembled length against the size in the sealed metadata.
Serve one targeted person a modified copy of the app that captures plaintext before it is encrypted, and serve everybody else the clean copy. There is no code signing in a browser and no signature on any of it, and nothing in Haven detects this. The two vendored crypto files are pinned by hash in the document, which stops a careless or supply-chain substitution and stops nothing here, because the operator writes the document as well as the file; the first-party code that draws the screen and holds the plaintext is not pinned at all. This defeats every entry in the right-hand column. Do that invisibly forever. Everything served is unminified, unbundled JavaScript you can read in devtools, so the modified bundle exists in a readable form on the machine of anybody who looks. That is a genuinely low bar to inspection and a genuinely poor defence, because nobody diffs the bundle on every page load. It is not nothing; it is close to nothing.
Keep every conversation key you have ever had, in every conversation where you did not turn forward secrecy on. The server retains each historical key version, still wrapped to each participant's long-term identity key. The setting that deletes them is off by default, is chosen per conversation, and was off for every conversation that existed before it shipped — so unless you went and switched it on, this row describes your account exactly as it always did. If they take the database now and crack your password in five years, they get the whole history, including conversations whose key was rotated specifically to cut somebody off. Unwrap any of it without your password. Identity keys are wrapped with PBKDF2-SHA256 at 600,000 iterations and never leave the browser unwrapped. That is the whole of what stands between a stolen database and your history — which is why the sentence to the left matters so much, and why your password length is doing more work here than in most products.
Read the recent past of a conversation even where you did turn it on. The current key is always held here, wrapped to you, and so is every superseded key still inside its grace period. A seizure today plus your password therefore recovers everything since the last key change, and up to one grace period plus one rotation interval before it — on the shipped defaults, about five weeks. The setting buys a bounded window, not a clean slate, and the section below states every edge of that window. Produce a key it has already retired. Once the sweep deletes your wrapped copy of a superseded key the row is gone: there is no archive, no backup path in the application and no operator command that restores it. That stretch of the conversation is readable on whichever of your devices cached the key before the deletion, and nowhere else at all — which is the same sentence read as a cost rather than as a protection.
See that you are connected right now — and, only if you armed the dead-man's switch, the day you last signed in. last_login is written only while the opt-in switch is armed, and only to the day, because it is the clock the switch counts down from; it is deliberately not gated on "hide presence", since that would quietly disarm a destructive feature somebody armed on purpose. With the switch off it is empty, and it used to be every sign-in to the second for every account. Separately, while you are connected the server is holding your socket open; no preference can hide a live connection from the machine terminating it. Keep a last-seen timestamp on an account that hides its presence. The preference stops the write, not just the read. Every site that stamps users.last_seen — both sign-in paths in server/routes/auth.js and the disconnect handler in server/sockets/index.js — runs an UPDATE ending WHERE id = ? AND hide_presence = 0, so the gate is in the statement and cannot be forgotten by a caller or raced against a preference change. Turning the setting on NULLs whatever was already stored, in the same statement. Rows written before the gate existed are cleared once at boot. The operator's own activity counters undercount as a result, and the code says so where they are computed.
Link two accounts by the device label on their sessions. Every live session stores a coarse browser-and-OS string — "Firefox on Linux". It is built from the User-Agent in server/middleware/auth.js and can only ever be one of five browser names or "Unknown browser", crossed with one of five OS names or "unknown OS"; the raw header is never stored. An uncommon label shared by two accounts whose sessions appear minutes apart is a strong hint that one person holds both, and it needs no ciphertext and no cleverness. A per-account preference stores 'Unknown device' instead — the same string the schema already defaults to, so a withheld label is indistinguishable from one the server never had. It is off by default and the cost is real: the label is how you recognise your own sessions, so turning it on means a stranger's session in your list looks like all the others. Recover your account for you, or for anyone else. There is no password reset and there cannot be one: a reset link would mean we held a way in. There is also no email address on any account to reset against, or to social-engineer — the column was dropped from the users table, so what an account holds is a username, a password hash and key material.
Read reactions, typing indicators, read receipts, disappearing-message settings and which messages you pinned or starred. These are stored in the clear. This row used to call them low-sensitivity metadata and no longer does: a reaction is attributed and timestamped, and a named person's reaction to a named message at a named time is content by any ordinary reading of the word. A starred message is a plaintext row naming which message you starred — which is a judgement about content, visible without the content. Read the name of a group created since group names were encrypted. New groups write nothing but the encrypted name — the old plaintext column is inserted as NULL. Groups created before that change still hold their name in plaintext, because deleting the old column would have destroyed existing names, and renaming a group (which an admin can do, and which stores only ciphertext) does not clear the legacy value either. If your group is old, assume its name is readable and make a new one if that matters.
Answer "who does this account talk to, and when" completely. If compelled, that is a demand we can satisfy in full. Note the exact scope of our "we cannot hand over messages" claim: it covers content, and only content. Silently substitute a verified contact's key. Keys are pinned on first sight in your own browser, and the pin now covers a contact's message-signing key as well as their identity key. A change raises a warning, and for a contact you verified out of band by comparing a 60-digit safety number, sending genuinely stops rather than warning and continuing. This is the trick ANOM's users had no defence against — but it only protects contacts you actually verified, and note the exact scope of the number: it is computed from identity keys alone, so verifying somebody out of band does not verify their signing key. First sight is still first sight.
Empty a message without leaving any trace of who emptied it. Nothing authenticates a deletion. The delete event carries no signature at all, so an operator blanking a message's ciphertext produces a row identical to the one the author deleting it produces. The transcript chain in the rows below proves that the message was there and that nothing was taken from around it; it does not prove who took the words out of it. Closing that needs a signed tombstone and a refusal to accept an unsigned one — a flag day for every client that has not updated — and it is not built. Write a message in your name. Every message carries a detached Ed25519 signature over a length-prefixed encoding of the conversation, the author, the key epoch, the sender's idempotency key, the nonce and the ciphertext; an edit is re-signed under a separate domain that names the message id. Changing the author, the room or the epoch of a stored row breaks that signature, and the recipient's app flags the row rather than quietly rendering it: ⚠ BAD SIGNATURE in red on the metadata line beside the timestamp, and a red outline around the bubble. The sender name a group message carries is still drawn above it — the badge sits next to the message rather than replacing its author, deliberately, because status text inside a bubble is text a forged message would get to render for itself. What you are shown is a name you are being told not to believe. The badge is on the message row only: a quote block and the pinned bar show the same message without it. The server also refuses an unsigned send from any account that has published a key, so "this account signs, but this particular message did not" is never an accepted state.
Hand you unsigned history. Messages written before signing existed carry no signature and never will — producing them now would need a server capable of making those signatures, which is a server capable of forging them. They are rendered unsigned rather than quietly blessed as verified, which is the honest treatment, but it is not a conclusive one: a message from someone who has not signed in since the change looks exactly the same. Replace the key your app checks signatures against. A verification key is published once, with a proof that the publisher holds its secret half, and a second, different key for the same account is refused outright. Without that, anyone holding a stolen session token could publish a key of their own and make every message that account had ever sent fail verification on every screen. Your browser checks against the key it pinned itself, never the one served alongside the message — and a signing key that moves while the identity key stays put is escalated more loudly than an ordinary key change, because the two are derived from one secret and there is no innocent way to produce that pairing.
Suppress the newest message in a conversation. The chain is a commitment to what came before, so a message with nothing after it has nothing referring back to it, and removing it is invisible. Only a later message closes that gap. The bottom of a range has the same shape: an operator serving short pages moves the verified floor up rather than setting off an alarm, so the strip names the message it verified from and says that older history was not checked. It cannot prove that a conversation goes back as far as it should. Remove a message from inside a range you are looking at. Every message now carries a second Ed25519 signature, over a payload that contains no ciphertext at all: the conversation, the author, the key epoch, the sender's idempotency key, and a set of parents — the messages their author had already seen, each named by a hash of its own chain payload. It is a graph rather than a line, because two people who send at the same instant have each seen a history the other's message is not in; they name the same parent and the graph reconverges, which is why a busy conversation does not raise an alarm on every turn. Take a message out and every message that named it reports a parent that is not there, and the app says so above the thread — ⚠ THIS TRANSCRIPT IS NOT COMPLETE, with a button that scrolls to the gap. chainPayload in server/lib/signature.js, auditContinuity in public/js/crypto.js.
Hand you pre-chain history for ever. Messages written before the chain existed cannot be chained retroactively — a server able to produce those signatures is a server able to forge them — so that stretch reads not checked permanently, and that is not evidence in either direction. A message whose author has since deleted their account is unverifiable for a different reason: sender_id becomes NULL, so its leaf cannot be recomputed, and the range reports itself partly checked rather than clean. Close the hole behind a deletion by rewriting what points at what. The parent list is the one field the message signature never covered, and that is precisely why the chain signs a payload of its own: were the parents unsigned, an operator deleting message B could rewrite message C's parents from B to A and the gap would seal itself. Rewriting them now breaks C's chain signature, which your browser checks against the key it pinned itself and never against one that arrived with the message.
Serve you a client that does not chain anything at all. This is the backdoored-bundle problem again, and the chain cannot decide it: a verifier delivered by the machine it is verifying can be replaced by the machine it is verifying. The two vendored crypto files are pinned by hash in the served document and the server refuses to start if a pin and a file disagree, which raises the cost of a careless substitution. It does nothing against an operator, who writes both the pin and the file. Make the warning worthless by deleting messages the ordinary way. The four legitimate paths — a delete, an edit, a disappearing-message expiry and a retention sweep — all leave the row, its id, its author and its chain fields exactly where they were, and a view-once burn runs the same delete statement as the first of them. The three removals blank the ciphertext and leave a tombstone; the edit replaces the ciphertext and re-signs the authorship signature, which is exactly why position is a second signature and not a wider first one — anything the send signature was the sole binding for would be released by the author's own edit button. Because the chain payload never covered the ciphertext, every one of them still hashes to the leaf its children named, so nothing fires. A legitimate removal empties a node; censorship removes one. An alarm that went off every time somebody deleted a message would be an alarm nobody reads, and the whole feature rests on that distinction.
Make the warning fire when nothing was taken. Corrupting one stored chain signature is enough, and an operator holding the database needs nothing else. It is worth naming because a participant can do it too: the send handler refuses a parent list an honest client could not have produced, a chain signature that does not verify against the account's published signing key, and chain fields arriving with no idempotency key — but it deliberately does not check that the parent ids named point at messages that exist. That would be an extra indexed lookup per parent on the hottest write path in the app, and it buys nothing against an operator who can write the row directly. So somebody already in the room can post a message naming a parent that was never there and put ⚠ THIS TRANSCRIPT IS NOT COMPLETE on everybody's screen. Training a room to ignore that warning is the most valuable thing anyone could do to this feature, and the trade is written into the send handler rather than left out of this page. Make your app call a tampered range unbroken. The verdict is computed on your device, from the messages you were handed and the keys this browser pinned, and there is no field in any response that sets it. Strip the chain off every row and the strip reads not checked, which is not a clean bill of health and is not presented as one; break one link and it reads not complete. The failure directions available to a server are silence and alarm. "Unbroken" is not one of them for a range it has interfered with — only for a range it has served short, which is a different gap and has a row of its own above.
Change what a message says without the chain noticing. The chain payload holds no ciphertext, no nonce, no file and no reactions — that omission is what lets a tombstone stay in the graph, and the price of it is that the chain is silent about content. Rewrite the bytes of a stored message and every parent reference in the range still resolves; the transcript strip goes on saying unbroken, because nothing was removed and the sequence is the only thing it speaks about. Have either signature cover for the other. Content is the message signature's job: it covers the nonce and the ciphertext, so altered bytes fail it and that row is drawn with ⚠ BAD SIGNATURE beside its timestamp. Position is the chain's job. The two are separate on purpose, each is silent about the other's subject, and neither is evidence for the other's claim — which is why both verdicts are shown, and why a range can read unbroken with a bad-signature row inside it.
Generate your two-factor codes — if they also have the server's key file. users.totp_secret must stay computable by the server: the second sign-in step carries no password, so there is nothing to lock it to. It used to sit in the database readable, and this row used to say that anyone holding the database could produce your second factor. It is now encrypted at rest under a key kept outside the database, so the database alone is not enough. An operator with root on the running server holds both halves and this row still applies to them. Two-factor here protects you against somebody who has your password. It has never protected you against the operator, and it still does not. Let the list of what it holds go quietly out of date. Settings → Your data → "What this server holds about you" is enumerated from the live schema at the moment you open it — PRAGMA table_info, plus the schema's own foreign keys to users to decide which tables hold account data — and never from a list somebody maintains by hand. Today that is 51 tables, 305 columns and 46 items: 38 the server can read, 7 it holds and has no key for, and 1 one-way hash — nine of those tables arrived with the commerce work, and describing them was not optional: an undescribed table takes the panel red. A column added by a migration that nobody classified makes the panel report itself incomplete, in red, above the list.
Keep your username after the account is deleted. admin_audit_log.target_username is copied onto every administrator action at the moment it is taken, so it outlives the account it names and stays readable by instance administrators. That is what an audit log which can still answer "who deleted this account" costs, and it is a cost rather than a feature. Answer "everything you hold about me" with less than it holds, quietly. The account export walks the same live schema the panel does, and every column belonging to an account must either appear in the file or be named inside it with the reason it was withheld — the three sign-in credentials, other people's messages, other participants' wrapped keys, the blocks made against you, and administrators' identities. A column that is neither exported nor explained is printed in the file's own manifest as unaccounted for, where the person holding the file can see it.
Put a domain binding in the log that nobody proved. When an account verifies that it controls example.org, this server performs the DNS or HTTPS lookup and writes what it found. A reader cannot repeat that lookup, so there is nothing in the published record that distinguishes a genuine check from an invented one. Anybody reading the word "verified" as "proved to me" has been misled, and the app deliberately does not use that word: it says the account proved control to this server, on a date, and names the server. Nor is there any DNSSEC validation, so somebody who can forge DNS answers to us can forge a binding. Make one and later deny making it — or quietly take one back. The domain log is append-only with signed tree heads, the same machinery as the key log and a separate tree under a separate key. A binding is signed, timestamped and published, and an operator who invents one has handed signed evidence of the invention to everyone who asked for it. Withdrawal is a second entry saying so; nothing can remove the first, including deleting the account, which withdraws every live claim and leaves the record that they existed. That permanence is stated on the screen that offers a claim, before the first one is made rather than after.
See that a call happened, between whom, when, and for how long — and carry its audio. Calls are restricted to relayed connections by default, which is what stops the two participants learning each other's IP addresses; the cost is that every packet crosses our relay, so we are placed to see both addresses, when the call started, how long it ran and how much data moved. Haven writes no call record to any database, but a relay logs what a relay is configured to log, and the signalling passes through us either way. Listen to it. WebRTC media is encrypted between the two browsers with DTLS-SRTP, and a relay forwards packets it holds no key for. There is also no fallback anywhere in the client: an instance with no relay configured reports calls as unavailable, with the reason shown before a microphone is opened, rather than connecting them directly and disclosing both addresses to make the call work.
Keep the timestamp after the message is gone. A retention window blanks the ciphertext and drops the attachment, but leaves the row behind as a tombstone with its sender and its time, and does not touch the participants table at all. A swept conversation still says who spoke to whom, and when, indefinitely. Recover the content it swept. The ciphertext column is emptied and the blob is unlinked from disk. What that cannot do — like every disappearing-message feature in every product — is reach a device that already synced the plaintext.

Every panel in the app that describes this server was served by it

This outranks everything the rows above say about the four surfaces Haven now shows you: the disclosure list, the account export, the per-conversation protection status behind each conversation's "⋯" menu, and the security check-up. Deriving the list from the live schema removes the way a page like that usually goes wrong — it cannot quietly fall behind a migration, because a column nobody has classified turns the panel red instead of dropping off it. It removes nothing about a server that wants to lie. The code drawing those panels arrived from the machine they describe, and an operator who would serve one person a modified bundle would serve them a modified panel in the same breath.

The app says this about itself, at the foot of the disclosure panel and again under the protection status, rather than leaving it to be worked out. What there is instead is the same weak check as everywhere else on this page: everything served is unminified and unbundled and can be read in devtools. That is a low bar to inspection and a poor defence, and it is the whole of what there is.

Adversary two

Someone who owns your device

Malware, a forensic tool, a border officer, or a person standing next to you. This is the adversary most likely to actually reach you, and the one where the honest summary is shortest: if they have your unlocked, signed-in machine, they have your messages.

Everything in the right-hand column narrows a window. None of it closes one.
What they can do What slows them down
Read everything the device can decrypt, if it is unlocked and signed in. Nothing in any encrypted messenger survives this. Code already running on the machine sees the plaintext at the moment you do. Auto-lock clears decrypted keys from memory after inactivity without a full sign-out, so a machine left alone stops being a machine that is signed in.
Capture your password as you type it — and with a copy of the database that is not just your current messages but, because old conversation keys are retained wherever forward secrecy was left off, your entire history. Where it was switched on, they get the window the forward-secrecy section describes instead of the lot. It also lets them sign as you: the message-signing key is derived from the identity key, so whoever recovers one holds both, and messages they write will verify on everybody else's screen. Keys never leave the browser unwrapped, so a device that has the stored blob but not the password has an inert file. Optional two-factor with single-use backup codes, and lockout after repeated wrong passwords.
Recover data after you "wipe" it. Clearing browser storage is not forensic erasure of a disk. Deleted pages can persist in free space, in swap, in backups and in system snapshots that Haven has no reach into. A panic key wipes local data on a keystroke, and a duress password wipes it while looking, to someone watching you type, like an ordinary unlock. Both are real and both are worth having.
Photograph the screen, or simply read it. There is no reliable web API to block screenshots or detect them, so Haven ships no toggle claiming to. View-once messages remove the persistent copy and nothing more. The app blurs itself behind a neutral placeholder when it loses focus, so content is not sitting in the OS app switcher, and notifications default to showing the sender only, because notification text lands on lock screens.
Know that Haven was used here. An account exists, a browser has storage for it, a device label is listed on the account. A duress wipe removes data, not the fact that there was data. Remote wipe and session revocation from another device you still hold, and a full list of every signed-in session with its label and last-active time, so a session you do not recognise is visible rather than silent. That list is the only account-compromise detection this product has, which is why the preference that withholds device labels from the server is off by default and says what it costs: turning it on relabels every new session 'Unknown device', and an intruder's session then looks exactly like yours.

A duress password only works against someone who does not know Haven has one

It is a good defence against a person who wants to see your messages and is watching you unlock. It is not a defence against an adversary who has read this page, knows the feature exists, and will simply ask a second time — or who has taken a disk image before making you type anything, in which case the wipe changes nothing. Plan for the adversary who has done their homework, because a well-resourced one has.

Adversary three

Someone watching the network

An ISP, a national-scale observer, whoever runs the café Wi-Fi, or anyone sitting on a link between you and the server. Transport is TLS, so the contents are protected twice over — once by TLS and once because the payload was already ciphertext before it was sent. The exposure here is not content. It is the fact of the connection.

The right-hand column is short and it is meant to be. TLS is doing its job; the problem is what TLS was never designed to hide.
What they can do What they cannot do
See that you connect to a Haven server, from which address, when, for how long, and how much data moved. A messaging app holds a live connection while you use it, so "when you were active" is visible to anyone on the path. Read anything you send. TLS protects the transport, and underneath it the message body was already encrypted in your browser. Breaking TLS would give them ciphertext.
Correlate your activity with somebody else's if they can watch both ends — the classic traffic-confirmation attack. Haven does no cover traffic, no padding of connection timing, and no delayed delivery, so there is nothing here working against that. See your IP if you reach the instance over Tor. Haven serves an Onion-Location header, so an instance published as an onion service advertises itself and Tor Browser will move you to it. This is the only mechanism that hides client addresses from the server as well as from the network.
Learn your timing from your push provider, if you enable push notifications. The payload deliberately carries no content and no sender name, but delivery runs through a browser vendor's push service, which sees your endpoint, your IP and the exact arrival time of every message. That is a per-message activity log held by a company neither of us has a relationship with. What the payload carries instead of a name is a fixed-width opaque ticket, the same 16 characters for every push, which your device alone resolves — so the provider cannot read a name and cannot infer one from the payload’s length either. Learn anything from this website. The marketing pages set no cookies, run no analytics, load no third-party fonts and fetch no CDN scripts. The whole site makes exactly one network request — a same-origin, credential-free, referrer-free GET of the public price list — and pages with no price on them do not make even that.

The newest guarantee, and the smallest

Forward secrecy: partial, opt-in, and off unless you switch it on

Until recently this page said Haven had no forward secrecy at all, and that every conversation key it had ever issued was still sitting on the server wrapped to your identity key. Part of that is no longer true, and the honest version of the change is narrower than the headline. A conversation's key now changes on a schedule; and if a participant asks for it, per conversation, the server deletes that participant's wrapped copy of each superseded key after a grace period. Since October 2026 a conversation can also turn on an optional per-message hash ratchet, which gives each message its own key and has each device destroy message keys 90 days after first use; it is not a Double Ratchet and does not recover after a key is stolen, and the ledger below is about the deletion setting, which the ratchet turns on for whoever enables it. Nothing below is a plan. Every sentence describes code you can read, and the verification section names the files.

What the setting actually buys, against what it leaves untouched. The left column is longer than the right on purpose. On a narrow screen each cell becomes its own labelled block.
What it does not protect What it does
Any key the server still holds. The current key is always on the server, wrapped to your identity key, because it is how your own other devices read the conversation at all. So is every superseded key still inside its grace period. Database seized today plus your password recovers everything since the last rotation, and up to a grace period plus a rotation interval before that — about five weeks on the shipped defaults of a seven-day rotation and a thirty-day grace. Both are operator-configurable, so on an instance that is not ours, check the numbers rather than these. Removes the server's copy of everything older than that window, for the participant who asked. runKeyRetirementSweep in server/db.js deletes the row from conversation_key_history outright, so your identity key then opens nothing for that key epoch and a password recovered later — by compulsion, by cracking, by a keylogger — no longer walks the thread backwards to its first message. That is the whole of the feature, and it is a real change in what a seized database is worth.
The other person's copy. The setting is per conversation and per participant, because the rows it deletes are per participant. If you switch it on and the person you are talking to does not, their wrapped copy of every key stays on the server, and the same seized database plus their password reads the entire thread — yours included. There is no way to ask for it on their behalf and no indication to you of what they chose. Cannot be imposed on you either. A group member switching it on is making a statement about their own recoverability, not setting a policy for the room; nothing is broadcast, and no participant's request can ever delete another participant's wrapped key.
A conversation that never rotates. Only a superseded key can be retired, so a conversation still on its first key gets nothing at all, no matter what the setting says. Rotating a group re-encrypts the group's name, and the ability to write that name is the rename capability — so rotating a group stays admin-only, and a group whose admins are all inactive keeps one key indefinitely. The app says so — rotationStallNotice in public/js/app.js is the sentence it shows, in the conversation and in the setting panel. The guarantee is still absent while it does. Rotates without anybody remembering to. A key older than the rotation interval is due, and so is one in a conversation somebody has just been removed from or has left — immediately, because the point of rotating after a removal is that the person who left cannot read what comes next. The server cannot rotate; it has never held a conversation key. It names one participant to do it, and a compare-and-swap refuses a second, so two clients acting at once cannot fork the key.
Against a client that lies. Before the server deletes anything it requires every one of your live devices to say it has stored the key. That is a claim, not a proof: the server cannot see the key and cannot verify that anything reached disk. A broken or malicious client can acknowledge without storing and destroy its own history. What it cannot do is destroy anybody else's. Counts devices, not accounts. The acknowledgement is scoped to one signed-in session, so a phone that synced the rotation cannot authorise the deletion of history a laptop never fetched. A user with no live session has nothing retired at all, because "every device agreed" and "no device was asked" must not resolve the same way. "Live" means a session that has not expired, and a session expires after seven days unused — which is how a device that has genuinely stopped being used stops blocking retirement for the ones still in use, and is also why the next row is true.
A device that was away too long, or arrived too late. A device offline for longer than the grace period loses that stretch of history permanently when it wakes — an absent device is indistinguishable from one that no longer exists. And a device that signs in after a retirement never had the key at all, so a new browser shows retired stretches as unreadable from the moment it is set up. That is intrinsic to forward secrecy rather than a defect, and it is what "readable only on devices that cached it" costs. Waits before it deletes. The grace period runs from the later of two clocks — when the key stopped being current, and when you turned the setting on — so switching it on today deletes nothing tomorrow. A key superseded a year ago still gets the full window, because the devices that have to store it have only just been told to. It also waits a little longer than the window says: the sweep walks the key timeline in windows with a resume cursor rather than examining every superseded wrap on the instance every hour, so a key that comes due behind the cursor is deleted on the next pass. On a large instance that is a few hours against a grace period of thirty days, and the alternative was an unbounded scan on the thread that serves every request.
Somebody who left before you turned it on. The choice to retire is recorded on your row in the conversation, and leaving a conversation deletes that row. So a member who departed before this feature existed, or who never turned it on, keeps their wrapped copy of every key the conversation has ever had — on the server, indefinitely. Nothing can reconstruct a consent that was destroyed with the row that held it, and the code deliberately does not guess one. There is a second edge: an ex-member can no longer acknowledge anything, so if they left with no live session, or with a device that had never stored that key, their wraps are kept as well. Narrowed, not closed, and the comment above findRetirableKeyWraps says so. Retires the keys of a member who left, if they had asked it to. This is the case rotation exists for and it was a real hole: an ex-member of a group is exactly the person a rotation is meant to lock out, and their wrapped copy of every earlier key used to survive on the server forever, because the query looked them up through a participant row that departure had already deleted. Their key-history rows are now stamped at the moment they leave — while the row and its setting can still be read — and the sweep retires them on the same grace clock they were already on, not a fresh one, because leaving is not a new instruction to anybody's devices.
A seized device you were logged into. The keys your devices keep are stored encrypted, under a key deriveEpochCacheKey derives from the identity secret — which exists in the browser only while the app is unlocked. So an adversary who has the device and the password, or who reaches it while it is unlocked, or who is running code in the page, reads that store like everything else on that machine.

Be exact about “only while unlocked”. That identity secret is held in sessionStorage for the tab, and browsers write sessionStorage to disk so a session can be restored after a crash or a restart. It is removed on lock, on sign-out and by the duress wipe, so the window is genuinely the unlocked session and no wider — but within that window the material is on the disk and not only in memory. A profile copied from a machine that was unlocked, and whose browser would have restored that session, can therefore carry it. The claim opposite is about a locked Haven, and that claim is unchanged.
Survives a locked device, a copied profile and another origin's script. Those are the commoner cases, and against them a store of raw keys for every epoch the server has been told to forget would have moved the weakness rather than removed it. A locked Haven cannot read its own key store, because the material it would need is not in the browser.
Anything except keys. It deletes no messages. The ciphertext stays on the server exactly as long as it would have anyway, and is removed only by a retention window if one applies. And the protection is backwards-looking only: an adversary who takes your key material now reads everything from now until the next rotation, which is what forward secrecy means and not a caveat on it. Nothing whatsoever for metadata. Who talks to whom, when, how often, and how many key epochs a conversation has been through are all still in the database and all still plain. If association is your danger, this section changes nothing you read in the first three, and the recommendation at the top of this page stands.

What turning it on costs, in one sentence

If you lose every device that stored a key, that stretch of the conversation is gone permanently, and nobody — including whoever runs the server — can restore it. That is the same trade Signal makes and it is the right one for the audience this page is written for, but it is a trade rather than an upgrade, which is why it is off by default, chosen one conversation at a time, and confirmed in words that say exactly this before the switch takes effect.

The list nobody puts on a homepage

What Haven does not defend against at all

Not "partially", not "we mitigate this". These are threats where Haven contributes nothing, and where believing otherwise is the dangerous move.

An adversary who can compel the operator

One modified JavaScript bundle, served to one account, and the encryption is irrelevant because the encrypting is done by the code we hand you. Subresource integrity is now real on the two vendored crypto files — their <script> tags carry SHA-384 hashes and the server refuses to start if a pin and a file disagree — and it narrows the careless case rather than this one, because the operator writes both the pin and the file. Release hashes are now published (RELEASE-HASHES.txt), and the app checks the bundle it is served against a signed release log by itself — but an operator willing to publish a modified build's digest to everybody passes that check. What would narrow this is a release independently reproduced by somebody outside this project, and a native signed client, and neither has happened. If this is your adversary, no browser-delivered encrypted service is safe for you — not Haven, not Proton, not Tuta.

Traffic and graph analysis

We do not resist this; we furnish it. The participants table is the graph, the timestamps are the schedule, and both are plain and indexed. There is no cover traffic, no sealed sender, no mixing and no delay. An analyst with the database does not have to be clever.

Retroactive decryption after a password compromise, on a default account

Out of the box, every historical conversation key is retained on the server wrapped to your identity key, and a password recovered at any point in the future opens everything that came before it. There is now a per-conversation setting that removes the older ones, but it is off until somebody switches it on, it leaves a window of about five weeks, and it does nothing about a participant who did not switch it on too — so for an account that has not been configured, this box is unchanged. Read what it does and does not cover before treating it as a fix. The optional per-message ratchet does not change this box: it limits what a device keeps, not what the server keeps. Signal, Wire and WhatsApp use a Double Ratchet by default and have none of this problem; it remains the single strongest technical argument against choosing Haven.

A contact who is compromised, careless, or not who you think

Every person in a conversation holds the key. They can screenshot, forward, be coerced, or have been the wrong person from the start. Safety numbers verify that a key belongs to the account you think it does; nothing verifies that the human behind the account is the human you meant. Removing someone from a group does not take back the key they already hold — rotating it does. That rotation is now flagged as due the moment somebody is removed or leaves, and a nominated participant's app performs it without being asked; in a group, only an admin can, so a group with no active admin stays on the key the departed member holds until one appears.

Being made to unlock it yourself

Law, a border, or force. The duress password helps against an observer who does not know the feature exists. It does nothing against someone who does, or who imaged the disk first. No software solves this and any product that implies it does is lying to you.

Deniability, which message signing has taken away

Signatures cut both ways and this is the side that costs you. Every message you send is signed by a key only you can produce, and that signature is stored on the server beside the ciphertext. It covers the ciphertext rather than the plaintext — so on its own it proves which account produced those bytes, and anyone who also obtains the conversation key (a participant who hands over their copy, or an adversary who cracks a password later) can bind you to the words. Before this, the author was an integer in the operator's database and could be denied. It cannot be now. The transcript chain adds a second signature of the same kind and it needs no conversation key to be read: its payload is the conversation, the author, the key epoch, the sender's idempotency key and a list of parent references — each a message id and a hash — so it proves to anybody holding the database that this account was in that conversation and committed to having seen those messages. That is a signed ordering of a conversation. Signal's protocol is deliberately deniable for exactly this reason; Haven's is not, and the trade was made knowingly in favour of catching a server that writes in your name and one that takes messages out of the middle.

Being deanonymised by your payment

Covered in full in the next section, and it belongs on this list: for this threat model, an invoice is worth more to an adversary than the ciphertext is. There is now a payment path that does not produce one — a prepaid voucher — and it is on this list anyway, because it removes the processor and not the operator: we still know which account redeemed which code.

Billing

Paying for this can undo it

Every claim on this site is about what a server can read. None of it survives a card statement that ties a legal name, a billing address and a bank account to a Haven username. Haven is unusual in holding no email address and no phone number for any account — neither has a column in the database to sit in — and a single card payment hands back everything that property was protecting. For an adversary who can reach a payment processor, and a well-resourced one can, the invoice is the strongest thing anyone here could be made to produce. Note the size of the claim, too: a missing email column is one identifier gone, not anonymity. Connections reach the reverse proxy over TLS without persistent IP logging, and the account uses a chosen pseudonymous username that somebody who already knows it exactly can look up. Only exactly: the lookup answers one full name and returns its public keys, never a list, never your status message and never when you were last seen. Until 18 September 2026 it was a prefix search from a single letter that returned twenty accounts at a time with both, which let anyone list every account on the instance. That is the version this sentence used to describe, and it is the reason the sentence now says look up.

The card path, in both columns

What it does not hand over: a card number, an expiry or a security code, none of which ever reach this server — they are typed into a page Stripe serves from Stripe's own origin, and this server learns a customer handle and an invoice row. Stripe is not told your username, and the product description on the charge is deliberately built from the plan rather than from anything about your account.

What it does hand over, which is the half that matters here: a permanent, readable link between this account and whatever name, address and bank record your payment processor holds. The account's own data panel names that row as the single most identifying value in the database. It exists only because somebody chose to pay by card.

The voucher path, which is the one that does not name you

A prepaid 24-character code that buys a set number of months on a set plan. An operator mints it, sells it however they and the buyer agree — cash, a bank transfer, Monero, a reseller — and the buyer redeems it in the app. Redeeming it creates no payment-processor record of any kind: no customer row, not even one with a null identifier; no network call to any third party, so nobody outside this instance learns the account exists; and no request for an address, because there is nowhere to put one. The code itself is stored only as a one-way hash, so a database dump, a backup or a subpoena does not hand out working vouchers, and nobody — including the operator — can recover a code after minting it.

And the exact boundary, in the register the rest of this page uses. One column, vouchers.redeemed_by, records that this account redeemed this code. It is unavoidable — "has this code been spent?" is a question the server has to answer — and it is admitted on the account's own disclosure panel rather than left to be found. So the unlinkability holds between Haven and the buyer, and only while the code changed hands somewhere Haven cannot see. If we sold you the voucher ourselves, by card, in your own name, we hold both ends and can join them. That is a real property and a narrower one than the word "anonymous", which is why this page does not use it.

What is not accepted, so nobody plans around it

Haven does not take Monero, Bitcoin or cash directly, and has no crypto processor of any kind. Those are ways you might pay an operator for a voucher, by arrangement with that operator — they are not buttons on this site and there is no code here that handles any of them. Mullvad runs cash by post in nine currencies, Monero, Bitcoin and vouchers against an account number with no email; that is where the voucher design came from, and it is also the accurate measure of how much further than us they have taken it.

Do not take our word for it

How to check every claim on this page

A vendor asserting its own threat model is worth very little. All of this is checkable, most of it in a few minutes, and the source is public. If any check below comes out differently from what this page says, the page is wrong and we want to know.

The social graph claim

Open server/db.js and find the conversation_participants table. It has a conversation_id and a user_id and no encryption anywhere on it, and there is an index on user_id. That is the entire basis for "the server holds the complete social graph": it is four lines of schema.

The retained-keys claim

Find conversation_key_history in the same file — one row per key version per participant, each holding that key wrapped to that participant's identity key. Then search the repository for DELETE FROM conversation_key_history. There is now exactly one, in runKeyRetirementSweep, and the six conditions it insists on are the next card. Everything that statement does not reach is retained forever, which is every conversation nobody has switched the setting on for.

The forward-secrecy claim

runKeyRetirementSweep in server/db.js is the only thing that deletes a key; read the six conditions above it, each of which must be true before a row goes — including "the version is below the conversation's current one", which is what makes retiring a live key impossible. The rotation policy, the nomination and the compare-and-swap are in server/routes/conversations.js, beside PATCH /:id/key-retirement, which is the whole of the opt-in: one column on your own participant row. deriveEpochCacheKey in public/js/crypto.js is what your browser's copy of an old key is sealed under, and its comment states what that does and does not protect against. Then run node test/run.js forward: the harness takes the adversary's position — the whole database plus a compromised identity key — and requires every wrap the server still holds to fail to open a retired epoch, with the same code required to succeed before retirement so the proof is not vacuous.

The timestamp resolution

The messages table's created_at defaults to datetime('now'), which SQLite renders to the second. No sub-second column exists, and no rounding or jitter is applied anywhere.

"Hide presence" stopping the write, not the read

Find touchLastSeen — it is defined twice, once in server/sockets/index.js and once in server/routes/auth.js, and both read UPDATE users SET last_seen = datetime('now') WHERE id = ? AND hide_presence = 0. The preference is a clause in the statement, not an if around the call. Then read updateHidePresence in server/routes/users.js: switching the setting on writes last_seen = CASE WHEN ? = 1 THEN NULL ELSE last_seen END in the same UPDATE, so the preference and the erasure cannot come apart. Anything stored before that gate existed is cleared once at boot by clearHiddenPresenceTimestamps in server/db.js. The CASE WHEN hide_presence = 1 still sitting in the read queries is now redundant, and the comment above it says exactly that.

The email address we do not have

This is a schema claim, so check the schema. Search server/db.js for email: the hits are the CREATE TABLE users that does not list it and the dropEmailColumn migration that rebuilds the table without it. Search public/index.html: the sign-up form has no such field. The one live mention left is in server/routes/auth.js, where POST /register accepts an email key and discards it unread — not validated, not stored, not echoed back — so that a cached client from before the change does not break mid-deploy. There is no column for it to be written into.

The padding, the KDF, the safety numbers

All three are in public/js/crypto.js and all three are single constants: PAD_BUCKETS runs 32 to 16384, KDF_ITERATIONS_DEFAULT is 600,000, and FINGERPRINT_ITERATIONS is 5,200. Read them rather than trusting the numbers quoted here.

The authorship claim

The signed wire format is written twice on purpose: canonicalPayload in server/lib/signature.js, and again in public/js/crypto.js beside deriveSigningKeyPair and signMessage. The set-once rule is POST /me/sign-key in server/routes/users.js — read the branch that answers 409. What your own browser trusts is computeAuthorship in public/js/app.js: it reads the key this device pinned, never the one that arrived with the message.

The transcript-continuity claim

The design note is the block comment above chainPayload in server/lib/signature.js, and it states what the chain cannot see as prominently as what it can. The wire format is written twice, like the message signature: once there, once beside chainLeaf in public/js/crypto.js. The verdict your browser draws is auditContinuity in that same file — read what each of its outcomes means, and note that a parent below the loaded page is the ordinary case rather than an alarm. chainHeadsFor, paintContinuity and buildContinuityBreak in public/js/app.js are the parent selection, the strip and the in-thread marker. For the limits, read the send handler: the comment above the three chain refusals in server/sockets/index.js is where the decision not to check that a named parent exists is set out, with the reason. Then run node test/run.js continuity: it performs four attacks against a real database and a real response, audits each with the shipped verifier, and then re-audits the same range with the chain stripped off every row and requires the attack to come back undetected — which is what shows the chain is doing the detecting. The four legitimate paths — a delete, an edit, an expiry and a retention sweep — are in the same harness and must not raise anything.

The list of what we hold about you

Open Settings → Your data → "What this server holds about you" in the app. The list is not in the page: describeSchema in server/lib/data-export.js walks the live database when you open the panel, and a column it cannot classify comes back in gaps and turns the panel red. auditHonesty in the same file is the second half — the unflattering facts on this page are pinned to a table and a column there, and relabelling one as encrypted, or dropping the sentence one of them carries, fails outright. node test/run.js assurance proves that by adding a real column and a real table to the running database and requiring the panel to report itself broken. The export's omissions are checked the same way, by exportCoverage, and travel inside the file rather than on this page.

One more thing to look at while you are in that file: assuranceLimiter in server/routes/users.js, which is the budget on all four /me/… routes. It is keyed on the account rather than the address, because every one of them is authenticated, and it is configured to emit no RateLimit-* headers at all — on an account-keyed bucket those headers are a per-account activity counter that anybody holding the token could poll.

The framed-file claim, and the half that is missing

server/routes/files.js holds both the code and the specification the browser has to match, written out at the foot of the file. The frame arithmetic is framedCiphertextBytes; the derived nonce is described in section 0 of that specification, and section 5 lists what the format does not cover, including that it does not hide a file's size and does not authenticate its frame count. Check the claim that the browser half does not exist the direct way: search public/js/ for noncePrefix, or for the /uploads routes it would have to call, and find nothing at all. node test/run.js backlog covers the server side — the session token, the frame arithmetic, range parsing at every boundary, an append proved constant time in what is already on disk, and that the whole-file path beside it is byte-for-byte unchanged.

The call-privacy claim

server/routes/ice.js decides the policy — relay-only unless an operator turns it off, and calls reported unavailable rather than downgraded when no relay exists. buildPeerConnection in public/js/webrtc.js is the only place a peer connection is built, and it takes iceTransportPolicy from that answer with no default of its own. Search the repository for stun.l.google.com: the only two hits are comments in those files explaining why it is gone.

The code you are actually running

Everything served is unminified and unbundled. Open devtools, read the JavaScript on the page, and compare it to the repository. Reading it yourself is the weakest check on the list, because nobody does it on every page load. The app does a narrower one by itself: at launch, before any key is unwrapped, whenever the build changed, and before a staged update is allowed to run, it hashes what it was served and asks the signed release log for proof that exact digest is in it. If the log cannot be reached it says so in a dismissible banner and carries on; if the log's signed head verifies and the digest is not in it, the app locks before any key is unwrapped. That catches a bundle served to you that was never published — not one the operator published to everybody.

That this site is not watching you

Open the network tab and load any page here. You will see the HTML, one stylesheet, one script and a favicon, all same-origin — plus, only on the pages that actually render a price, a single credential-free request for the public price list. This page, the security page and every page in the footer make no request at all. The one other request this site is capable of is the enquiry form, which fires when you press its button and never otherwise. No analytics, no fonts, no CDN, no beacon. Nothing to take on faith; it is right there in the panel.

The verification that actually protects you

Compare a 60-digit safety number with a real contact, out of band — read it on a call or scan the QR in person. Then have them sign in on a new device and watch what happens: the banner appears and sending stops. That is the one protection on this page you can test end-to-end yourself in five minutes, and it is worth doing before you rely on it.

The tests, and what they do not prove

Run npm test. It needs no network and covers the security fixes described on the security page, including the group-privacy bug and the authorship work above — node test/run.js authorship runs the browser and server halves of the signing format against each other and then attacks a booted server with re-attributed, unsigned and replayed messages. It is a regression net, not an audit — nobody outside this project has reviewed the cryptography, and we will keep saying so until somebody has.

The questions to ask us, and everyone else

"What can you hand over if compelled?" Ours, as a list: message ciphertext; every conversation key, current and historical, wrapped to each participant — except the superseded ones a participant asked us to retire and the grace period has since removed, which we cannot produce for that participant because the row is gone; the participants table, which is the social graph; message timestamps to the second; attachment sizes; last-seen times, except for accounts that hide their presence, where the column is NULL; sign-in times; usernames — including the usernames of deleted accounts, wherever an administrator acted on one, because the audit log copies the name onto the row; the TOTP secret of any account with two-factor turned on, which is stored in the clear because the server computes codes from it; the coarse device label on each session, unless that account asked for it to be withheld; and, for every message written since signing landed, the author's signature over it and the account's public verification key — which together let a third party confirm which account produced a given ciphertext, as the box on deniability above says plainly. Since the transcript chain landed, each message also carries a second signature over its position in the conversation, which names the messages its author had already seen: that is an ordering of a conversation, signed by its participants, and it is on this list rather than left implied. Not message content and not filenames. Group names only where the group predates sealed names — an older group still holds a readable one, and this list said otherwise until an audit of the schema caught it. And, for any account that has paid by card, an email address: there is no email column on the account, but the payment processor's notifications are stored here verbatim so their signatures stay checkable, and for a card payment those carry what you typed on the processor's page — your email address, and depending on what it asked for your name, postal address and the card's last four digits. We cannot send anything to it. We hold it. A free or voucher-paid account leaves no such record. Demand this from any vendor as a list rather than a slogan, and notice how much of ours is not the content.

"Has it been audited, by whom, and when?" Ours: no, never. If a vendor says yes, ask for the report, the date, and whether the scope covered the thing you care about.

"What happens when someone leaves a group?" Ours: they keep the key they already have, in every system that exists. Rotating the key is the step that actually cuts them off, and a removal or a departure now marks the conversation as due for one immediately — a nominated participant's browser performs it, and Haven also prompts the person doing the removing. In a group only an admin can rotate, because rotating re-encrypts the group's name and writing that name is the rename capability, so a group whose admins are all away does not rotate at all and says so. A vendor who answers this one smoothly has not understood the question.