DEV Community

Cover image for How We Built Chat Encryption( From First Idea to a Stable Design)
Md. Fardin Khan
Md. Fardin Khan

Posted on

How We Built Chat Encryption( From First Idea to a Stable Design)

How We Built Chat Encryption — From First Idea to a Stable Design

A plain-language story of what broke, what we changed, and the architecture we ship today.

When we said “all messaging is encrypted,” we meant something specific: our company should not be able to read your chat text from our servers. Only the people in the conversation on their own phones and browsers should unlock messages.

Getting there was not a single release. It was a series of designs, real product problems, and fixes. This post walks that path from the start to the stable system we use now including the final architecture and the main chat action flows (login, send, receive, encrypt, re-key).


What “end-to-end encryption” means here

Think of a courier delivering a lockbox.

  • You lock the message on your device before it leaves.
  • Our servers carry and store the lockbox.
  • The other person’s device unlocks it.
  • We never hold the private key.

If the server creates the keys, the server can open every box. That is encryption at rest, not end-to-end. We rejected that early. It would break the privacy promise.


Starting point: plaintext chat

Before encryption, messages were normal text in the database and over the wire. Chat worked well. Anyone with server or database access could read everything.

Why we changed: privacy protocol and user trust. Messaging content should be confidential by default.

Architecture at that time (simple):

Phone / Web  →  Server  →  Database
                (stores readable text)
                →  other devices
Enter fullscreen mode Exit fullscreen mode

Step 1 — First encryption design (one key per person)

What we built

  • Each user generates a key pair on their device.
  • Only the public key is uploaded.
  • Direct messages: derive a shared secret between two people, then lock the text.
  • Groups: each sender has a sender key for that group. Share it with members, then lock each later message once (same idea WhatsApp uses for groups).
  • Server role: public-key directory + store/deliver ciphertext. No private keys.

What this looked like

Sender device                Server                    Recipient device
─────────────                ──────                    ────────────────
Create keys locally
Upload public key  ───────►  Key directory
Lock message       ───────►  Store ciphertext  ─────►  Unlock with private key
Enter fullscreen mode Exit fullscreen mode

Issue we hit next

Encryption was treated as required. If the other person (or one group member) had never opened the new app/web, they had no keys. Send failed. Chat looked broken for most existing users.

Lesson: You cannot flip a switch to “everyone must encrypt” in a live product with old clients still in the wild.


Step 2 — Soft rollout: encrypt if possible

Problem

Hard cutover blocked messaging whenever anyone lacked keys. Groups were worse: one member without keys blocked the whole send.

Solution

  • If everyone in the conversation has keys → encrypt.
  • If anyone is missing keys → send plaintext and keep chat working.
  • New clients generate and upload keys on login in the background.
  • Never put private keys on the server to “migrate” old users (that would not be E2EE).

What users experience

Situation Behavior
New client ↔ new client Encrypted
New ↔ old (no keys yet) Plaintext, still delivers
Later, old user upgrades Future messages can encrypt

Important boundary we locked later: plaintext is for rollout, not for “this group is too big.” Once keys exist, we encrypt — including large groups.


Step 3 — Early decrypt bugs (still one device per user)

After encryption shipped, several “Unable to decrypt” cases appeared. None were “encryption is fake”; they were edge cases in how keys were derived or loaded.

Issue (user view) Cause Fix
DM fails between two people Both sides didn’t derive the same secret Align DM key agreement (identity × identity)
Can’t read your own group message after refresh Own sender key wasn’t resolved from local storage Fall back to locally stored sender key for self
Some members never get group keys Member list used for crypto was incomplete (~paginated UI) Dedicated full member-id API for encryption
Own DM breaks after navigating away Peer resolution sometimes treated you as the peer Harden peer id; never fetch your own public bundle as the peer

Lesson: E2EE is as much about product edge cases (reload, pagination, “my own bubble”) as about algorithms.


Step 4 — Multi-device: phone + web for the same account

Problem

Same account in normal browser and Incognito (or phone + web). Send from one → Unable to decrypt on the other.

Why

We stored one public key per user. The second login overwrote the first. Private keys live only in that browser. Device A cannot open a message sealed for Device B’s public key.

Solution (encryption version 2)

  • Each install gets a stable device id.
  • Server stores many public bundles per user: (userId, deviceId).
  • DMs: one locked message body + small envelopes of the message key for:
    • each of the peer’s devices
    • your other devices
    • this device (so your sent messages stay readable after reload)
  • Groups: share the sender key to each member device, not just each member.
  • Cap later: max 5 devices so fan-out stays bounded (similar to linked-device limits elsewhere).
One message key
   ├─ lock text once → ciphertext
   ├─ envelope for peer phone
   ├─ envelope for peer web
   ├─ envelope for my phone
   └─ envelope for my web
         ↓
      Server stores ciphertext + envelopes
      (cannot open envelopes)
Enter fullscreen mode Exit fullscreen mode

Lesson: “One account” in the product sense is many devices in the crypto sense.


Step 5 — Scale without dropping encryption (stable design)

Problem

Privacy says all messaging is encrypted. Turning encryption off for “groups over N members” was rejected.

But our group path, while shaped like WhatsApp Sender Keys, still did expensive work on every send:

  1. Re-share sender key wraps to everyone every message
  2. Fetch each member’s keys with one request per user (N+1)
  3. Wasteful database work when listing keys
  4. Unbounded devices → larger and slower fan-out

What WhatsApp taught us (conceptually)

  • Once (first speak / membership change): share the sender’s group secret with members’ devices.
  • Every later message: lock once; server copies the same blob to everyone.
  • Cap linked devices.
  • Don’t disable encryption because the group is large.

What we changed

Change Everyday meaning
Distribute only when needed Heavy work on first send / new device / after join-leave; later sends stay fast
Batch key lookup One request for many members’ public keys
Short-lived cache Don’t re-download the same public keys constantly
Stop wasteful key-list side effects Listing public keys doesn’t burn one-time keys
Max 5 devices Keeps multi-device cost predictable
Always encrypt when keys exist Large groups stay encrypted

Cost model (for engineers and curious readers):

  • Warm group send → cheap (lock once, upload once)
  • Cold path (first send / rotate / new device) → more work, paid rarely

Final stable architecture

This is the system we run today. Read it as a map of who does what, then follow the action flows for login, send, receive, and re-key.

Big picture

Overall Flow

In plain language: your device locks the words, our servers carry and file the lockbox, their device opens it. We never hold the private key.

System architecture — layers

System architecture

Layer Job
Web and mobile apps UI: thread list, message list, composer. Ask crypto to lock/unlock; never invent keys in the UI layer.
On-device crypto service Create device keys, soft-rollout checks, DM envelopes, group sender keys, decrypt for history and live messages.
Realtime socket + HTTP Socket for live send/edit/react/typing; HTTP for history, media, and the public key directory.
Backend chat service Auth, save messages, fan-out to members, unread and conversation previews.
Backend key directory Store public device keys and wrapped group secrets; signal re-key on membership change. Never private keys.
Database Conversations, messages, public keys, wraps, read state, media.

Conversation model

Every thread is a Group — a community chat or a one-to-one DM. DMs are the same container with a DM chat type. There is no separate “Conversation” table. That keeps membership, delivery, and unread logic in one place.

Conversation Model

An encrypted message stores a sealed body (ciphertext), not readable text. Direct messages also store small device envelopes. Group messages store a sender key id so recipients know which shared secret to use.

Two encryption modes

Direct message — invent a one-time message key, lock the text once, put a sealed copy of that key in an envelope for each relevant device (theirs and yours).

Group — share your sender key when needed; every later message from you is one locked blob for the whole group (WhatsApp-style Sender Keys).


Chat action flows

Flow A — Login / device setup

When someone opens the current app or web build while signed in, encryption sets itself up in the background. No extra button.

Login/ Device steup flow

What the user notices: nothing dramatic — chat still works. Behind the scenes, this install published its public lock so others can encrypt to it.


Flow B — Send a text message (full path)

This is the main action flow: tap Send → maybe encrypt → server stores → everyone receives → each device unlocks.

Text Message flow

Step by step in words:

  1. User taps Send.
  2. Client asks: can everyone who needs to read this unlock a lockbox?
    • No → send plaintext (rollout only; chat must not break).
    • Yes → DM uses envelopes; group may share a sender key, then lock once.
  3. Transport: prefer realtime socket; fall back to HTTP if needed.
  4. Server saves the message and fans it out to members in the room.
  5. Each peer device unlocks before showing the bubble (or shows plaintext / “unable to decrypt”).

Flow C — Soft-rollout gate (decide encrypt vs plaintext)

Soft-rollout gate

Plaintext here means “someone has not upgraded yet,” not “this group is large.”


Flow D — Direct message encrypt / decrypt

Direct message encrypt / decrypt

Why envelopes: one locked message body; phone and web for the same account each get their own sealed copy of the message key.


Flow E — Group message (cold vs hot)

Sharing a group secret with every member’s devices is expensive. We do it when needed; later messages stay cheap.

Group message (cold vs hot)

Moment What happens
First encrypted send in a group (from you) Create your sender key, distribute wraps, then lock this message
Later sends from you Reuse sender key; lock once; no redistribute
Someone joins or leaves Server signals re-key; clients drop old local sender key; next send shares a new one
Another member sends They use their sender key (created on their first encrypted send)

Flow F — Open history or a live message

Open history or a live message

History and live messages use the same unlock rules. Failure shows a clear placeholder instead of garbage characters.


Flow G — Membership change / re-key

Membership change / re-key

Product meaning: when the roster changes, chat re-locks itself so departed members should not keep reading new messages under the old group secret.


Soft rollout vs privacy promise

  • Missing keys → plaintext so chat doesn’t die during upgrade.
  • Everyone has keys → always encrypt, any group size.
  • Old history before encryption → stays as it was (we don’t rewrite the past on the server).

What is / isn’t encrypted today

Content Encrypted end-to-end?
Text when all peers have keys Yes
Text while someone lacks keys No (temporary rollout)
Photos / files / voice Not yet
Typing, reactions, presence, group name No (delivery metadata)

Old groups: do we create sender keys up front?

No mass migration. Existing groups sit unchanged until someone with a modern client sends text and every member has keys.

Then, for that sender only:

  1. Create a sender key (if none locally)
  2. Distribute wraps to member devices
  3. Encrypt that message
  4. Reuse the same sender key on later sends

Other members create their own sender key the first time they send encrypted text. History from before encryption stays plaintext.


Decision log (what we refused and why)

Tempting shortcut Why we didn’t
Server generates keys for all users Server could read everything → not E2EE
Block send until everyone encrypts Breaks production chat during rollout
Plaintext for large groups Contradicts “all messaging is encrypted”
One public key per account Phone + web cannot both decrypt
Rebuild full Signal Protocol in one go Slower path; Sender Keys + device envelopes got us to a stable product shape

Takeaways

  1. True E2EE is a client problem as much as a server problem — private keys must stay on devices.
  2. Ship a bridge (encrypt-if-possible) or you will break existing users.
  3. Multi-device is not optional if people use phone and web.
  4. Scale encryption by doing expensive work rarely, not by turning encryption off.
  5. Product edge cases (own messages, incomplete member lists, account switch) define whether users trust the feature.

Closing

We started with readable messages on the server, then added locks, then learned that locks without a migration story feel like outages, then learned that one lock per account fails on a second browser, then aligned group encryption with the industry pattern: expensive once, cheap always, without abandoning the privacy promise.

The stable system today is: device-scoped keys, soft rollout for missing peers, WhatsApp-style group sender keys, and a server that relays sealed boxes it cannot open.

That is how our chat encryption went from first idea to something we can stand behind in production.

Top comments (0)