End-to-end encryption (E2EE) sounds simple: encrypt on the sender's device, decrypt on the recipient's, and the server in the middle only sees ciphertext. When building E2EE Chat, the harder part wasn't the algorithms but one question: what if the server itself cheats?
This post walks through the app's key design, which libsodium primitive does which job, and the general limits every design of this kind has to accept. If you're learning about E2EE, I hope it helps you see how the pieces fit together.
System overview
| Part | Stack |
|---|---|
| Web app (PWA) | React 19, TanStack Router and Query, Tailwind CSS 4, libsodium |
| API | Fastify 5, Drizzle ORM on PostgreSQL, Socket.IO, Web Push |
| Files | S3-compatible object storage |
| Accounts | Keycloak, OpenID Connect with PKCE |
Reads and writes go through REST, and every change (new messages, typing, read markers…) is pushed in real time over Socket.IO. The server creates a user on their first API call, after verifying the Keycloak access token.
The core property: the server only ever sees ciphertext. Messages, reactions, attachment contents, and even attachment metadata (name, type, size, duration) are encrypted in the browser.
The keys
| Key | Algorithm | Stored |
|---|---|---|
| Identity key pair | X25519 (crypto_box_keypair) |
Public key on the server; secret key only in IndexedDB |
| Signing key pair | Ed25519, derived from the X25519 secret key | Published with the identity key; never stored on its own |
| Keyring backup | Argon2id (crypto_pwhash) + secretbox, passphrase of 12+ characters |
On the server, readable only by its owner |
| Chat key | 32 random bytes, sealed to each member (crypto_box_seal) |
On the server, one copy per member |
| Messages and files | secretbox (XSalsa20-Poly1305) with a crypto_kdf subkey |
On the message |
A key per conversation
Each conversation has a 32-byte symmetric key. Whoever creates it seals it to every member's X25519 public key (crypto_box_seal, a "sealed box": encrypted to the recipient's public key, openable only with the matching secret key) and uploads the sealed copies. The server holds them but can't open any.
Keys are versioned. When group membership changes, the key rotates: the server only accepts a membership change together with a new key version. Members who leave don't get the next key, and the next person to send a message creates it. Chat keys are append-only, so old versions stay and old history stays readable.
Losing a device
The secret key lives only in the browser's IndexedDB. To use the app on another device, the user sets a passphrase. The keyring is encrypted with a key derived from it through Argon2id before it's stored on the server. The passphrase must be at least 12 characters and not on a list of guessable ones, because Argon2id only slows guessing down; it can't save a weak password.
The hard part: not trusting the server
Encryption stops the server from reading. But it still stores and relays everything, so it could:
- make up a chat key itself (and therefore know it),
- quietly add a member to a group,
- swap someone's public key for its own,
- hold back messages, reorder them, or replay old ones.
No design can prevent all of this, because the server still stores and relays everything. The realistic goal is to make it detectable on the user's side.
Everything is signed
Each user has an Ed25519 signing key, derived from their X25519 secret key (keyed BLAKE2b, then crypto_sign_seed_keypair) and published alongside the identity key. Because it's derived, the signing key never needs storing or backing up on its own: recovering the X25519 secret key recovers it too. Everything a member produces is signed:
- Chat key versions, over the chat, version, creator and recipients. A key the server made up isn't used to send. A member the server added on its own gets no key.
- Messages and edits, over the sender, key version, content, reply target and attachment. A message that doesn't check out is marked "Unverified".
- Unsends, reactions, chat previews and notifications are all checked. A notification never shows content that doesn't verify.
Message chains
A signature proves who wrote something, not that the server delivered everything. So each member's messages in a chat form a signed chain (the same idea as a tamper-evident audit log): numbered 1, 2, 3…, each naming the newest message its sender had seen. From that, the app can detect:
- messages held back (a gap in the numbers, or a message that refers to one this device never got),
- messages shown out of order,
- an old message replayed under a new id, or a version older than one already seen.
Each device keeps a ledger in IndexedDB of what it has seen and what the user trusts (contacts' keys, their own earlier keys). The ledger is backed up for the user's other devices, encrypted with a key only their keyring can derive.
When someone's key changes
The app trusts the first key it sees for a contact, an approach usually called TOFU (trust on first use), as in SSH. After that, if someone's key changes, the app stops encrypting for them until the user accepts the new key. For stronger assurance, two people compare safety numbers outside the chat: by scanning each other's QR code in person, reading them out, or sending one through another app. A mismatch is never marked as verified.
General limits of this approach
Knowing a technique's limits matters as much as knowing how it works. Any design that detects tampering with signatures and message chains, like the one above, shares these:
- Detection depends on what comes later. A withheld message only shows up when another message refers to it. That's why protocols such as MLS add agreement on the group's state.
- Each device only remembers what it has seen. A new device has to trust what it's given until more evidence arrives.
- E2EE protects content, not metadata. The server still knows who talks to whom, and when. Reducing metadata takes quite different techniques, such as Signal's sealed sender.
Small details that matter
- Web Push carries no message content, because the server has none to send.
- Privacy settings (hide online status, no read receipts, no typing indicators) are enforced by the server and apply both ways, like WhatsApp: turn read receipts off and you don't see other people's either.
- Upload limits: files not attached to a message count against a 200 MB per-user quota and are deleted after 24 hours.
- Deleting an account requires a sign-in within the last 5 minutes. Public keys are kept, so what that person signed still verifies.
- Testing: the server logic is tested against an in-memory PostgreSQL (PGlite), and end-to-end flows with Playwright.
If you're building your own
Designing it yourself is a good way to understand each problem in E2EE. But for a product with real users, start from an audited protocol: the Signal Protocol for one-to-one chat, or MLS (RFC 9420) for groups. In cryptography, the problems worth worrying about are usually the ones you haven't thought of, and those protocols have had many people thinking about them.
You can try the app at chat.vuminhhoa.com.
Summary
- The server should only ever see ciphertext: messages, reactions, attachment contents and attachment metadata are all encrypted in the browser.
- Each conversation has a versioned 32-byte key, sealed to every member with
crypto_box_seal, and the key rotates whenever membership changes. - A keyring backup encrypted with an Argon2id-derived key lets users move to a new device, but Argon2id only slows guessing, so the passphrase must be long and not on a guessable list.
- Encryption stops the server from reading, not from cheating. The realistic goal is to make cheating detectable on the user's side.
- Sign everything with an Ed25519 key derived from the X25519 secret key, and chain each member's messages so withheld, reordered or replayed messages show up.
- Trust a contact's first key, stop encrypting for them when it changes, and compare safety numbers outside the chat for stronger assurance.
- Detection depends on later messages, a new device has to trust what it's given, and metadata stays visible. For a product with real users, start from the Signal Protocol or MLS.
If you've designed end-to-end encryption for an app of your own, I'd love to compare notes. My email is on the home page.