DEV Community

Jin Xu
Jin Xu

Posted on Fully Autonomous

I built a P2P disappearing-message app with React Native and WebRTC. Here's what was harder than expected

Most "private" messengers still ask for your phone number. I wanted something closer to passing a note: share a short code with someone you already know, talk, and when you're done there's nothing left on either phone.

That became IncogSnap, an Android app (iOS coming) built with Expo / React Native. You create a room, get a 6-digit code (or a QR / link), your friend types it in, and the two phones talk directly over WebRTC. No account, no phone number, no email.

This post is about the parts that turned out harder than they looked.

The stack

  • Expo SDK 57 / React Native 0.86
  • react-native-webrtc for the peer connection and DataChannel
  • tweetnacl for crypto
  • Firebase Realtime Database for signaling only (exchanging SDP offers/answers and ICE candidates)
  • expo-screen-capture to block screenshots and screen recording

1. "Peer-to-peer" doesn't connect on its own

My first version used public STUN servers only. On the same Wi-Fi it worked every time. Then I tested one phone on mobile data and one on home Wi-Fi, and a large share of connections simply never came up.

Carrier-grade NAT and symmetric NAT are the usual culprits: STUN lets each phone discover its public address, but some NATs won't accept traffic from a peer they haven't talked to first. The fix is a TURN relay as a fallback, over both UDP and TCP:

iceServers: [
  { urls: ['stun:…:3478', 'turn:…:3478?transport=udp', 'turn:…:3478?transport=tcp'], username, credential },
]
Enter fullscreen mode Exit fullscreen mode

The trade-off is worth being honest about: when TURN is used, the relay sees both IP addresses. It can't read anything, because the payload is already end-to-end encrypted before it reaches WebRTC, but "nothing ever touches a server" stops being a true sentence. I removed that claim from the store listing.

2. End-to-end encryption on top of WebRTC

WebRTC already encrypts the transport (DTLS-SRTP), but the keys are negotiated through the signaling channel. If the signaling server were compromised, it could in principle sit in the middle. So each room also does its own key exchange:

  • Each device generates an X25519 key pair (nacl.box.keyPair()).
  • Public keys are exchanged, and both sides derive the same shared key with nacl.box.before().
  • Every message, photo and clip is sealed with nacl.secretbox (XSalsa20-Poly1305) and a fresh random nonce.

To make a man-in-the-middle detectable, both phones show a short fingerprint of the two public keys. My first version used 4 bytes, which turned out to be too short: brute-forcing a colliding key pair at 32 bits is cheap. It's now 8 bytes (64 bits), shown as four groups of four hex characters so two people can read it aloud:

export function sessionFingerprint(a: string, b: string): string {
  const h = nacl.hash(utf8Encode([a, b].sort().join('|')));
  const hex = Array.from(h.slice(0, 8)).map((n) => n.toString(16).padStart(2, '0')).join('').toUpperCase();
  return hex.match(/.{1,4}/g)!.join(' ');
}
Enter fullscreen mode Exit fullscreen mode

3. On mobile, "offline" is the normal state

Pure P2P has an awkward property: if the other phone isn't connected right now, your message has nowhere to go. And on mobile the OS suspends background apps aggressively, so "the other phone isn't connected right now" happens constantly.

A lost message looks exactly like a broken app, so I added a small encrypted mailbox for 2-person rooms:

  • If the live DataChannel send fails, the message (already encrypted with the session key) is written to rooms/{code}/mailbox/{role}/{msgId} in Firebase.
  • When the other phone reconnects, it drains the mailbox and deletes each entry.
  • Firebase only ever holds ciphertext. It never has the key.

To make reconnecting possible, each device stores its room key pair in SecureStore instead of generating a new one on every launch. A room where nobody has been connected for an hour is closed for good: the record and its mailbox are deleted, and both phones wipe their keys.

4. Disappearing timers when two clocks disagree

"Delete this message 5 minutes after it's seen" sounds simple until you remember that two phones never agree on the time, and either user can change the timer mid-conversation.

What worked for me:

  • The timer value travels with each message, so a message keeps the setting it was sent with, even if someone changes the timer later.
  • Timer changes are their own protocol message (timer-set) with a timestamp, so both sides apply the same change in the same order.
  • The countdown starts on each device when that device displays the message, so clock skew between the phones doesn't matter.

5. App Store rules for content you can't read

Apple's guideline 1.2 requires apps with user-generated content to offer reporting, blocking and a way to act on abusive users, even if you can't see the content. With no accounts and E2E encryption, all of it has to work around that:

  • Text runs through an on-device word filter before display.
  • Report sends the reported user's anonymous ID and a reason to a moderation queue, never message content.
  • Block ends the chat and prevents that ID from ever connecting to this device again.
  • A confirmed ban is enforced both by the app (checked at launch and on resume) and by the database rules, so a banned ID can't create or join rooms.

What I'd do differently

  • Test across networks on day one. Same-Wi-Fi testing hid the NAT problem completely.
  • Write the privacy copy last. Every architecture change (TURN, mailbox, push) made a previously true sentence false. I now treat the privacy policy as part of the code review.
  • Design for "offline" first. The mailbox should have been in v1.

Try it

IncogSnap is free on Android (ad-supported, no ads inside chats or the camera):
https://play.google.com/store/apps/details?id=com.bzotech.incogsnap

It's closed source for now. I'm considering publishing the crypto and protocol module, so if you'd find that useful, tell me in the comments. I'm also happy to go deeper on any of the WebRTC or crypto parts.

Top comments (0)