<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Vansh Kashyap</title>
    <description>The latest articles on DEV Community by Vansh Kashyap (@techbyvansh).</description>
    <link>https://dev.to/techbyvansh</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4157203%2F9e0272f0-7136-455e-b3b8-8bbed3fb55ae.png</url>
      <title>DEV Community: Vansh Kashyap</title>
      <link>https://dev.to/techbyvansh</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/techbyvansh"/>
    <language>en</language>
    <item>
      <title>I built a chat app where the server has never seen a single message</title>
      <dc:creator>Vansh Kashyap</dc:creator>
      <pubDate>Fri, 02 Oct 2026 11:02:43 +0000</pubDate>
      <link>https://dev.to/techbyvansh/i-built-a-chat-app-where-the-server-has-never-seen-a-single-message-549n</link>
      <guid>https://dev.to/techbyvansh/i-built-a-chat-app-where-the-server-has-never-seen-a-single-message-549n</guid>
      <description>&lt;p&gt;Every mainstream messenger makes your phone number your identity. Change apps,&lt;br&gt;
change phones — it follows you. Join a group with strangers and you've handed&lt;br&gt;
every one of them a permanent way to reach you.&lt;/p&gt;

&lt;p&gt;I wanted to see what was left if you simply deleted that field. So I built&lt;br&gt;
&lt;strong&gt;&lt;a href="https://vanshlink.lzworth.in/" rel="noopener noreferrer"&gt;Mynah&lt;/a&gt;&lt;/strong&gt;: a chat app with no phone-number&lt;br&gt;
input anywhere, where the server only ever holds ciphertext, and where you can&lt;br&gt;
open a room that strangers join by code without making an account at all.&lt;/p&gt;

&lt;p&gt;Here's how it actually works.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwq8t5p8j6vefl1xquqkx.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwq8t5p8j6vefl1xquqkx.jpg" alt="Mynah home page: " width="800" height="551"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The real Mynah home page. Two doors: an account with an @username, or a quick room with no sign-up.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The constraint that shapes everything
&lt;/h2&gt;

&lt;p&gt;The rule I started with was deliberately absolute:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The server must never be able to read a message, even if someone takes the whole database.&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;p&gt;Everything below is a consequence of that.&lt;/p&gt;
&lt;h2&gt;
  
  
  Sealing happens on the device
&lt;/h2&gt;

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

&lt;p&gt;Every message, photo and voice note is encrypted in the browser with the&lt;br&gt;
&lt;strong&gt;Web Crypto API&lt;/strong&gt; before it goes anywhere. Two primitives do the work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ECDH&lt;/strong&gt; to agree on a shared key between two parties&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AES-GCM&lt;/strong&gt; to seal the payload with that key&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The shape of it, using the browser's own crypto:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Each user has a keypair. Public keys are exchangeable; private keys never leave.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;keyPair&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generateKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ECDH&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;namedCurve&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;P-256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                 &lt;span class="c1"&gt;// non-extractable: the private key cannot be read out&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;deriveKey&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Derive a shared AES key from my private key + their public key.&lt;/span&gt;
&lt;span class="c1"&gt;// Both sides independently arrive at the same key. It is never transmitted.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sharedKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deriveKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ECDH&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;public&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;theirPublicKey&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nx"&gt;myPrivateKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;AES-GCM&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;encrypt&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;decrypt&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Seal. The IV is random per message and travels with the ciphertext.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;iv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRandomValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ciphertext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;AES-GCM&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;iv&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nx"&gt;sharedKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextEncoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;plaintext&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details that matter more than they look:&lt;/p&gt;

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

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

&lt;h2&gt;
  
  
  The server is a dumb pipe with a short memory
&lt;/h2&gt;

&lt;p&gt;The backend relays ciphertext and holds it for &lt;strong&gt;24 hours&lt;/strong&gt;. That's it. Message&lt;br&gt;
history lives on your device.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Rooms with no sign-up: the key is the room code
&lt;/h2&gt;

&lt;p&gt;The feature I like most is the one that needed the least code.&lt;/p&gt;

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

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

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

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

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzih0id4cux3xdb66atro.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzih0id4cux3xdb66atro.jpg" alt="Mynah chat rooms page: create a private room in one tap, share the code, and it deletes itself" width="800" height="551"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The chat-rooms page: no account needed to join, encrypted with the room code, auto-delete.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Making "private" not feel slow
&lt;/h2&gt;

&lt;p&gt;This was the real engineering problem.&lt;/p&gt;

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

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

&lt;p&gt;Calls are the same idea taken further: &lt;strong&gt;WebRTC&lt;/strong&gt;, peer to peer, so media never&lt;br&gt;
transits a server at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The advanced features around the encryption
&lt;/h2&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Username identity, not a number.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rooms with real controls.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Share it your way.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Say it now, send it later.&lt;/strong&gt; Type the message, hold Send, pick a time, and Mynah delivers it on schedule, even if your phone is off.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Voice notes that behave.&lt;/strong&gt; Hold to record, lock to keep recording hands-free, and pause when you need to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Installable, no app store.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg3ti9y7s0g52ss1ty4iz.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg3ti9y7s0g52ss1ty4iz.jpg" alt="Mynah features page showing the phone and desktop chat UI with end-to-end encrypted, no phone number and self-deleting room badges" width="800" height="551"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The features page: a chat on phone and desktop, the end-to-end encrypted badge, no phone number, and a room that deletes itself.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell someone starting this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Write the one rule down first.&lt;/strong&gt; "The server must never read a message" made
every later decision obvious, including the painful ones.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use the platform's crypto.&lt;/strong&gt; &lt;code&gt;crypto.subtle&lt;/code&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Non-extractable keys are free.&lt;/strong&gt; One flag, and an entire class of key-theft
bugs stops being possible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deleting a field beats protecting it.&lt;/strong&gt; No phone number means no phone number
to leak. That's not a security feature you maintain; it's one you build once.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Mynah is live at &lt;strong&gt;&lt;a href="https://vanshlink.lzworth.in/" rel="noopener noreferrer"&gt;vanshlink.lzworth.in&lt;/a&gt;&lt;/strong&gt; —&lt;br&gt;
open it, make a room, send a code to someone. I wrote up the longer build story&lt;br&gt;
&lt;a href="https://vanshkashyap.lzworth.in/mynah-encrypted-chat-app" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Happy to answer anything about the crypto or the room-key derivation in the&lt;br&gt;
comments.&lt;/p&gt;




&lt;p&gt;I'm Vansh Kashyap, a full-stack and AI automation developer in New Delhi and a&lt;br&gt;
co-founder of &lt;a href="https://lzworth.in" rel="noopener noreferrer"&gt;LZ Worth&lt;/a&gt;. I've shipped 18 live web apps and 8&lt;br&gt;
Android apps — all of them open and checkable at&lt;br&gt;
&lt;a href="https://vanshkashyap.lzworth.in" rel="noopener noreferrer"&gt;vanshkashyap.lzworth.in&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Code: &lt;a href="https://github.com/Obitouchiha002" rel="noopener noreferrer"&gt;github.com/Obitouchiha002&lt;/a&gt; ·&lt;br&gt;
LinkedIn: &lt;a href="https://www.linkedin.com/in/techbyvansh" rel="noopener noreferrer"&gt;in/techbyvansh&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>security</category>
      <category>javascript</category>
      <category>react</category>
    </item>
  </channel>
</rss>
