DEV Community

Vansh Kashyap
Vansh Kashyap

Posted on Originally published at vanshkashyap.lzworth.in

I built a chat app where the server has never seen a single message

Every mainstream messenger makes your phone number your identity. Change apps,
change phones — it follows you. Join a group with strangers and you've handed
every one of them a permanent way to reach you.

I wanted to see what was left if you simply deleted that field. So I built
Mynah: a chat app with no phone-number
input anywhere, where the server only ever holds ciphertext, and where you can
open a room that strangers join by code without making an account at all.

Here's how it actually works.

Mynah home page:
The real Mynah home page. Two doors: an account with an @username, or a quick room with no sign-up.

The constraint that shapes everything

The rule I started with was deliberately absolute:

The server must never be able to read a message, even if someone takes the whole database.

That single line kills a lot of convenient features. No server-side search. No
server-side notification previews. No "scan messages for links and generate a
preview card." The backend cannot do any of it, because it has no idea what it's
holding.

Everything below is a consequence of that.

Sealing happens on the device

Mynah is a React + TypeScript PWA with Supabase behind it — Postgres with
row-level security, Realtime for delivery, Storage for media. But Supabase never
gets plaintext.

Every message, photo and voice note is encrypted in the browser with the
Web Crypto API before it goes anywhere. Two primitives do the work:

  • ECDH to agree on a shared key between two parties
  • AES-GCM to seal the payload with that key

The shape of it, using the browser's own crypto:

// Each user has a keypair. Public keys are exchangeable; private keys never leave.
const keyPair = await crypto.subtle.generateKey(
  { name: "ECDH", namedCurve: "P-256" },
  false,                 // non-extractable: the private key cannot be read out
  ["deriveKey"]
);

// Derive a shared AES key from my private key + their public key.
// Both sides independently arrive at the same key. It is never transmitted.
const sharedKey = await crypto.subtle.deriveKey(
  { name: "ECDH", public: theirPublicKey },
  myPrivateKey,
  { name: "AES-GCM", length: 256 },
  false,
  ["encrypt", "decrypt"]
);

// Seal. The IV is random per message and travels with the ciphertext.
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt(
  { name: "AES-GCM", iv },
  sharedKey,
  new TextEncoder().encode(plaintext)
);
Enter fullscreen mode Exit fullscreen mode

Two details that matter more than they look:

extractable: false. The private key is a handle the browser will use but
will not hand back to JavaScript. Even my own code cannot read it out. That
closes off a whole category of "someone injected a script" problems.

AES-GCM, not AES-CBC. GCM is authenticated — if a byte of the ciphertext is
altered in transit, decryption fails loudly instead of returning plausible
garbage. You want tampering to be an error, not a surprise.

The server is a dumb pipe with a short memory

The backend relays ciphertext and holds it for 24 hours. That's it. Message
history lives on your device.

This is the part people find uncomfortable, and it's the honest trade: if you
lose the device, that history is gone. There is no "restore from our cloud,"
because there is no readable copy in the cloud to restore from. You can't have
both properties. I picked the one the app is named for.

Rooms with no sign-up: the key is the room code

The feature I like most is the one that needed the least code.

A chat room in Mynah doesn't need an account. You share a short code, people
join, you talk, the room deletes itself. The way that works is that the room's
key is derived from the room code itself.

Nobody registers. Nothing is stored against a person. The server holds ciphertext
it cannot open, keyed by something it was never told. And when the room is gone,
the key is gone with it — there's nothing left to decrypt even in principle.

It's worth noticing what happened there: the sign-up step wasn't secured, it
was removed. The strongest version of protecting user data is usually not
collecting it.

The obvious trade is that anyone with the code can read the room. That's the
correct behaviour for the thing it is — a disposable room, not a private DM. The
two live side by side in the app, with different threat models and different
affordances, and I think being explicit about that is better than pretending one
mechanism covers both.

Mynah chat rooms page: create a private room in one tap, share the code, and it deletes itself
The chat-rooms page: no account needed to join, encrypted with the room code, auto-delete.

Making "private" not feel slow

This was the real engineering problem.

If every message is sealed on the device, the server can't index, search, sort or
fan anything out for you. The naive version of that is an app that feels sluggish
because the client is doing work the backend used to do.

What made it feel instant was pushing the effort into delivery instead of
processing. Messages go out over per-user realtime channels and are decrypted on
arrival — the client only ever decrypts what it's actually about to show. The
server stays blind, and the user doesn't pay for it.

Calls are the same idea taken further: WebRTC, peer to peer, so media never
transits a server at all.

The advanced features around the encryption

Encryption is the foundation, but a chat app people actually use needs more than that. Here is what is live in Mynah today:

  • Username identity, not a number. You sign up with an email and a username. Friends find you by @username, user ID or QR code, you can change the username any time, and you can block anyone in one tap.
  • Rooms with real controls. A room can be live, 1 hour, 1 day or 7 days. Anyone can start a 1-hour room with no account; 1-day and 7-day rooms need a free account. Inside a room you get polls, pinned plans and an optional password.
  • Share it your way. A room can be shared as a code, a link, a QR, or a ready-made poster for an Instagram story. Friends open the link, type a name and they're in.
  • Say it now, send it later. Type the message, hold Send, pick a time, and Mynah delivers it on schedule, even if your phone is off.
  • Voice notes that behave. Hold to record, lock to keep recording hands-free, and pause when you need to.
  • Installable, no app store. It is a PWA: open it in any browser or add it to your home screen for a full-screen app with notifications. One account works on one phone, one tablet and one computer at a time.

Mynah features page showing the phone and desktop chat UI with end-to-end encrypted, no phone number and self-deleting room badges
The features page: a chat on phone and desktop, the end-to-end encrypted badge, no phone number, and a room that deletes itself.

What I'd tell someone starting this

  • Write the one rule down first. "The server must never read a message" made every later decision obvious, including the painful ones.
  • Use the platform's crypto. crypto.subtle is in every modern browser, it's audited, and it's constant-time where it needs to be. Rolling your own, or pulling in a crypto library you can't review, is the actual risk.
  • Non-extractable keys are free. One flag, and an entire class of key-theft bugs stops being possible.
  • Deleting a field beats protecting it. No phone number means no phone number to leak. That's not a security feature you maintain; it's one you build once.

Mynah is live at vanshlink.lzworth.in —
open it, make a room, send a code to someone. I wrote up the longer build story
here.

Happy to answer anything about the crypto or the room-key derivation in the
comments.


I'm Vansh Kashyap, a full-stack and AI automation developer in New Delhi and a
co-founder of LZ Worth. I've shipped 18 live web apps and 8
Android apps — all of them open and checkable at
vanshkashyap.lzworth.in.

Code: github.com/Obitouchiha002 ·
LinkedIn: in/techbyvansh

Top comments (0)