Every WebRTC tutorial starts the same way: spin up a Node server with Socket.IO, exchange SDP through it, done. That server sees who talks to whom, when, and from which IP. You have built a peer-to-peer media path with a centralized metadata collector bolted to the front.
This article is about removing that server. Not replacing it with a "privacy-friendly" one, removing it. By the end you will have two browsers exchanging encrypted messages over a direct data channel, with no backend of your own anywhere in the picture, and a connection descriptor small enough to fit in a single QR code.
I have been building this architecture for a while in SecureBit.chat, an open-source P2P messenger, so most of the sharp edges below are ones I have already cut myself on.
What "serverless" actually means here
Be precise about the claim, because WebRTC people are rightly suspicious of it.
Removed: the signaling server. No backend accepts your offer, stores it, or forwards it. No account, no session, no server-side log of who connected to whom.
Still there, conditionally: STUN and TURN. STUN is a single UDP round trip that tells you your public address. It sees an IP and nothing else. TURN is a relay, and if both peers sit behind symmetric NAT, traffic goes through it. TURN is a server. Anyone claiming zero infrastructure while quietly running TURN is selling something. What you can do is make TURN the fallback rather than the default, use short-lived credentials, and accept that the relay sees encrypted bytes and packet timing but not content.
Not solved by any of this: offline delivery. Two peers must be online simultaneously. That is the price.
The signaling problem
WebRTC needs both sides to exchange an SDP blob before a connection exists. Chicken and egg: you need a channel to establish a channel.
The server-free answer is that the humans are the transport. They already have one: a Signal thread, a phone call, a shared screen, physical proximity. Your job is to make the payload small enough that a human can move it.
A raw offer SDP looks like this:
v=0
o=- 4611731400430051336 2 IN IP4 127.0.0.1
s=-
t=0 0
a=group:BUNDLE 0
a=extmap-allow-mixed
a=msid-semantic: WMS
m=application 60379 UDP/DTLS/SCTP webrtc-datachannel
c=IN IP4 203.0.113.7
a=candidate:2999745851 1 udp 2113937151 192.168.1.42 60379 typ host ...
a=candidate:1846856196 1 udp 1677729535 203.0.113.7 60379 typ srflx ...
a=ice-ufrag:kR8s
a=ice-pwd:5MqTJcLmZXvB2nH9pWdA7fUe
a=fingerprint:sha-256 8F:2A:...:C1
a=setup:actpass
a=mid:0
a=sctp-port:5000
a=max-message-size:262144
That is 1.5 to 4 KB depending on how many candidates ICE found. Base64 it and you are past 2000 characters. A QR code tops out around 2953 bytes in the largest, densest version, which no phone camera reads reliably. Copy-pasting a 3 KB string into a chat is a bad user experience and people will mangle it.
So compress it. Aggressively.
Shrinking the descriptor
Almost everything in that SDP is a constant your code already knows. The actual per-connection entropy is five things:
Field Size
DTLS fingerprint (SHA-256) 32 bytes
ICE ufrag ~4 bytes
ICE pwd ~24 bytes
Reachable candidate (IPv4 + port) 6 bytes
Setup role + flags 1 byte
That is 67 bytes. Everything else gets rebuilt from a template on the other side.
Note that host candidates like 192.168.1.42 are useless to a peer on a different network. Keep the server-reflexive one, and relay candidates only if you are using TURN. Filtering candidates also cuts a real privacy leak: your LAN topology stops being broadcast to whoever you send the descriptor to.
function extractDescriptor(sdp) {
const one = (re) => (sdp.match(re) || [])[1];
const candidates = [...sdp.matchAll(/^a=candidate:(.+)$/gm)]
.map((m) => m[1])
.filter((c) => /typ (srflx|relay)/.test(c));
const best = candidates[0];
if (!best) throw new Error('no reachable candidate');
const parts = best.split(' ');
return {
fingerprint: one(/^a=fingerprint:sha-256 (.+)$/m).replace(/:/g, ''),
ufrag: one(/^a=ice-ufrag:(.+)$/m),
pwd: one(/^a=ice-pwd:(.+)$/m),
ip: parts[4],
port: Number(parts[5]),
setup: one(/^a=setup:(.+)$/m),
};
}
Rebuilding is a template fill:
function buildSdp(d) {
const fp = d.fingerprint.match(/.{2}/g).join(':').toUpperCase();
return [
'v=0',
'o=- 0 0 IN IP4 127.0.0.1',
's=-',
't=0 0',
'a=group:BUNDLE 0',
'a=msid-semantic: WMS',
'm=application 9 UDP/DTLS/SCTP webrtc-datachannel',
`c=IN IP4 ${d.ip}`,
`a=candidate:1 1 udp 2113937151 ${d.ip} ${d.port} typ srflx raddr 0.0.0.0 rport 0`,
'a=end-of-candidates',
`a=ice-ufrag:${d.ufrag}`,
`a=ice-pwd:${d.pwd}`,
`a=fingerprint:sha-256 ${fp}`,
`a=setup:${d.setup}`,
'a=mid:0',
'a=sctp-port:5000',
'a=max-message-size:262144',
'',
].join('\r\n');
}
The line order is not decorative. Browsers parse SDP strictly and will reject a rearranged block with an unhelpful error.
Now serialize the struct. CBOR gives a compact binary encoding, and base64url makes it QR- and URL-safe:
import { encode, decode } from 'cbor-x';
const PREFIX = 'SB2:';
const b64u = (bytes) =>
btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
export function pack(descriptor) {
return PREFIX + b64u(encode(descriptor));
}
export function unpack(text) {
if (!text.startsWith(PREFIX)) throw new Error('unknown descriptor format');
const raw = text.slice(PREFIX.length).replace(/-/g, '+').replace(/_/g, '/');
return decode(Uint8Array.from(atob(raw), (c) => c.charCodeAt(0)));
}
In SecureBit.chat this format is SB2: and lands between 98 and 149 bytes, which encodes to a QR version 6 to 8. Those scan instantly from a phone screen across a table. The earlier format packed key material into the descriptor too and needed a much denser code; moving the key exchange in-band after the channel opens is what shrank it. More on that below.
Getting the offer
One trap: createOffer() returns an SDP with no candidates yet. ICE gathering is asynchronous. If you serialize immediately you get a descriptor that connects to nothing.
Wait for gathering to complete, with a timeout, because a peer that cannot reach STUN will otherwise hang forever:
const ICE_SERVERS = [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun.cloudflare.com:3478' },
];
function gathered(pc, ms = 4000) {
return new Promise((resolve) => {
if (pc.iceGatheringState === 'complete') return resolve();
const done = () => {
pc.removeEventListener('icegatheringstatechange', check);
clearTimeout(timer);
resolve();
};
const check = () => pc.iceGatheringState === 'complete' && done();
const timer = setTimeout(done, ms);
pc.addEventListener('icegatheringstatechange', check);
});
}
export async function createInvite() {
const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS });
const channel = pc.createDataChannel('msg', { ordered: true });
await pc.setLocalDescription(await pc.createOffer());
await gathered(pc);
return { pc, channel, invite: pack(extractDescriptor(pc.localDescription.sdp)) };
}
The responder side:
export async function acceptInvite(inviteText) {
const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS });
const channel = await new Promise((resolve) => {
pc.ondatachannel = (e) => resolve(e.channel);
});
const offer = buildSdp(unpack(inviteText));
await pc.setRemoteDescription({ type: 'offer', sdp: offer });
await pc.setLocalDescription(await pc.createAnswer());
await gathered(pc);
return { pc, channel, response: pack(extractDescriptor(pc.localDescription.sdp)) };
}
The initiator pastes the response back, calls setRemoteDescription, and the channel opens. Two manual hops, no server.
DTLS is not end-to-end encryption
WebRTC data channels are encrypted with DTLS. People stop here and call the app E2EE. That is wrong for a specific reason.
DTLS protects the transport between two ICE endpoints. It authenticates nothing about who is on the other end beyond a fingerprint you received over an untrusted channel. If an attacker sits between the two humans while they exchange descriptors, they hand each side their own fingerprint, terminate DTLS on both legs, and read everything. DTLS is doing its job perfectly the entire time.
So put your own crypto on top, keyed by material the attacker never touched.
Once the channel is open, run an ECDH exchange in-band:
async function exchangeKeys(channel, isInitiator) {
const kp = await crypto.subtle.generateKey(
{ name: 'ECDH', namedCurve: 'P-256' },
false,
['deriveBits'],
);
const mine = new Uint8Array(
await crypto.subtle.exportKey('raw', kp.publicKey),
);
const theirsRaw = await new Promise((resolve) => {
channel.addEventListener('message', (e) => resolve(new Uint8Array(e.data)), {
once: true,
});
channel.send(mine);
});
const theirs = await crypto.subtle.importKey(
'raw', theirsRaw, { name: 'ECDH', namedCurve: 'P-256' }, false, [],
);
const shared = new Uint8Array(
await crypto.subtle.deriveBits(
{ name: 'ECDH', public: theirs }, kp.privateKey, 256,
),
);
// Transcript binds the session to both public keys in a fixed order.
const [a, b] = isInitiator ? [mine, theirsRaw] : [theirsRaw, mine];
const transcript = new Uint8Array([...a, ...b]);
const root = await hkdf(shared, transcript, 'root');
shared.fill(0); // zero the raw secret once derived
return { root, transcript };
}
async function hkdf(secret, salt, info) {
const key = await crypto.subtle.importKey('raw', secret, 'HKDF', false, ['deriveBits']);
return new Uint8Array(
await crypto.subtle.deriveBits(
{ name: 'HKDF', hash: 'SHA-256', salt, info: new TextEncoder().encode(info) },
key, 256,
),
);
}
Zeroing shared after derivation matters more than it looks. Raw ECDH output sitting in a live heap is exactly what a later memory-disclosure bug turns into a full session compromise.
Closing the MITM hole with a verification code
The ECDH above still does not prove identity. What proves it is the humans comparing a short code derived from the transcript, out of band, over a channel the attacker does not control.
async function shortAuthString(transcript) {
const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', transcript));
const n = ((digest[0] << 16) | (digest[1] << 8) | digest[2]) % 100000;
return String(n).padStart(5, '0');
}
Both sides display five digits. If a machine-in-the-middle inserted its own keys, the two transcripts differ and the codes differ. Users read them aloud on a call or glance at each other's screens.
The design rule that makes this actually work: treat verification as the only transition into the trusted state, and deny everything before it. Until the local user has explicitly confirmed the code matches, the app should refuse to render incoming messages, refuse to accept file transfers, and refuse control frames that could change session state. A verification prompt that users can dismiss while messages already flow behind it is decoration.
Message encryption and forward secrecy
With a root key, encrypt each message under a key derived fresh from a ratcheting chain, so compromising one key does not expose earlier traffic:
class Session {
constructor(root) { this.chain = root; this.counter = 0; }
async #step() {
this.chain = await hkdf(this.chain, new Uint8Array(0), 'chain');
const material = await hkdf(this.chain, new Uint8Array(0), 'msg');
return crypto.subtle.importKey('raw', material, 'AES-GCM', false, ['encrypt', 'decrypt']);
}
async seal(plaintext) {
const key = await this.#step();
const iv = crypto.getRandomValues(new Uint8Array(12));
const seq = this.counter++;
const aad = new Uint8Array(4);
new DataView(aad.buffer).setUint32(0, seq);
const ct = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv, additionalData: aad },
key,
new TextEncoder().encode(plaintext),
);
return { seq, iv, ct: new Uint8Array(ct) };
}
}
The sequence number goes into the AEAD's additional data, which is what stops an attacker from reordering or replaying frames without the tag failing. A per-message symmetric chain gives you forward secrecy within a session; combining it with a periodic Diffie-Hellman ratchet gives you post-compromise recovery as well. SecureBit.chat runs both, and the DH step is where a long-running session heals after a key leak.
Hardening the file and media paths
Two peers with a direct channel can send each other anything, and the receiver's parser is now attacker-facing. A few decisions that came out of auditing this in production:
The receiver decides what it accepts. Enforce a MIME allowlist, a per-file size cap, and a total session budget on the receiving side. Sender-declared metadata is a hint, not a constraint.
Bound your decompressors. If you accept compressed payloads or scan QR codes, abort decompression at a hard byte ceiling. A 30 KB zip bomb that expands to 4 GB will take the tab down.
Ephemeral means ephemeral. View-once content must not reach OS notification payloads, and pending invitations should not go to localStorage where any XSS reads them later.
None of this is WebRTC-specific. It becomes urgent because there is no server in the middle doing sanitization for you. Removing the server removes its filtering too.
What you get and what you give up
Ship this and you have a messenger where no infrastructure of yours observes a conversation, no account exists to subpoena, and the connection metadata never leaves the two devices. That is a genuinely different threat model from federated or self-hosted alternatives.
The costs are real. Both peers must be online at once. Descriptor exchange takes a manual step, and no amount of UI polish makes that as frictionless as typing a username. Symmetric NAT on both ends means TURN or no connection. Group chat requires either a mesh, which stops scaling past a handful of participants, or a different topology.
If those trade-offs fit what you are building, the architecture is sound and the browser gives you everything you need. The full implementation, including the Rust cryptographic core used by the desktop and mobile clients, is at github.com/SecureBitChat. Issues and audit findings welcome.
Top comments (0)