Minimal WebRTC handshake

Author's note: drafted by Deepseek v4.1 Flash


The 23-Byte WebRTC Handshake

How small can a browser-to-browser WebRTC handshake get? I spent a weekend measuring it in Chromium 157 (Microsoft Edge) and Safari 26.2. The short answer: 23 bytes per direction, if you're willing to make a few very specific assumptions.


The 2015 answer was 106 characters

In 2015, Philipp Hancke wrote a great piece called The Minimum Viable SDP. The question was simple: how much do two browsers actually need to exchange before a DataChannel works?

The answer was about 106 characters per direction:

O,<ufrag>,<pwd>,<base64-fingerprint>,<ip>,<port>

That was a hack, and it came with caveats: a TURN relay, no trickle ICE, and a 30-second window to paste the answer. But the core insight was right: most of an SDP is boilerplate, not per-connection state.

I wanted to know what that number looks like today, on current browsers, on a LAN, with no TURN server. So I built a small test harness and measured it.

The result is a nice ladder:

What you send Per direction Assumptions
Full Chrome SDP ~568 B none
Full Safari SDP ~587 B none
Minimal SDP, SHA-256 279 B hand-written SDP, LAN IP known
Minimal SDP, SHA-1 241 B same, SHA-1 (experiment only)
Parameterized, raw, SHA-256 55 B shared template, LAN IP known, port only
Parameterized, raw, SHA-1 43 B same, SHA-1
Pre-shared certificate 23 B fingerprint pinned out-of-band
Custom ICE stack 2 B not browser WebRTC

The interesting part is not the 23 bytes. It's which 23 bytes, and what you have to give up to get there.


What actually has to be exchanged

A WebRTC DataChannel is three layers stacked on top of each other:

  1. ICE gives you a UDP path.
  2. DTLS gives you encryption and identity.
  3. SCTP over DTLS gives you the DataChannel.

Each layer needs a small amount of per-connection state:

Layer What must be exchanged Why
ICE ice-ufrag, ice-pwd, candidate (IP + port) STUN username and MESSAGE-INTEGRITY
DTLS certificate fingerprint authenticate the peer's self-signed cert
DTLS setup role who is DTLS client/server
SCTP sctp-port, max-message-size can be defaulted
Signaling offer/answer role can be implied by message type

Everything else in a normal SDP — codecs, rtpmap, fmtp, extmap, msid, ssrc, rtcp-mux, b=AS, BUNDLE, c= — is either irrelevant for a DataChannel or can be hardcoded.

The per-connection random values are remarkably small:

That's the whole handshake, information-theoretically.


The test setup

I used:

One important detail: modern browsers hide host candidates behind mDNS:

a=candidate:... 1 udp ... <uuid>.local 64062 typ host

Safari always did this in my tests. Chrome headless did too, unless I passed --disable-features=WebRtcHideLocalIpsWithMdns. A web page cannot turn mDNS off. So the "minimal SDP" numbers depend on whether you send the browser's .local candidate or reconstruct a raw IP:port candidate on the receiving side.

For the LAN experiments, I did the latter: the signaling layer knows the peer's IP, so the candidate only needs the UDP port.


The SDP floor

The SDP parser is stricter than you'd think, but also more forgiving than the spec suggests.

Mandatory at session level:

v=0
o=- 0 0 IN IP4 0
s=-
t=0 0

Mandatory per DataChannel m-line:

m=application 9 DTLS/SCTP x
a=ice-ufrag:<4+ chars>
a=ice-pwd:<22+ chars>
a=fingerprint:sha-256 <colon-hex>

The m= line needs at least four fields. DTLS/SCTP is the shortest accepted protocol token. The fourth field can be any token; x is the shortest.

That's basically it. You can delete:

A working minimal SDP, SHA-256, LF, with a known LAN IP:

v=0
o=- 0 0 IN IP4 0
s=-
t=0 0
m=application 9 DTLS/SCTP x
a=candidate: 1 udp 1 <LAN_IP> <PORT> typ host
a=ice-ufrag:<UFRAG>
a=ice-pwd:<PWD>
a=fingerprint:sha-256 <SHA256_FP>

That's 279 bytes per direction. Swap SHA-256 for SHA-1 and it's 241 bytes. Drop the candidate and trickle it later, and the first SDP is 191 bytes (SHA-1).

Both Chrome and Safari accepted this template. The Safari-specific surprises:

  1. a=sctp-port cannot be combined with DTLS/SCTP x. If you include a=sctp-port, the m-line must be m=application 9 UDP/DTLS/SCTP webrtc-datachannel, otherwise you get sctp-port attribute can't be used with sctpmap.
  2. Safari uses .local candidates by default. If you send the browser's candidate verbatim, the SDP is 308/270 bytes (SHA-256/SHA-1). If you reconstruct the candidate with the known LAN IP, it drops to 279/241 bytes.
  3. Safari accepts SHA-1 fingerprints. It exposes the certificate DER via getStats().base64Certificate, so you can compute a SHA-1 fingerprint yourself. Don't use this in production.

Chrome ↔ Safari works in both directions at 241 bytes with SHA-1, and 279 bytes with SHA-256.


Parameterized signaling: send only what's random

Once you know the SDP floor, the next step is obvious: don't send the SDP at all. Both sides share the same template, and the wire carries only the values that differ.

sender:
  browser creates its normal local SDP (never modify it)
  extract ufrag, pwd, fingerprint, candidate
  encode them into a compact payload
  send

receiver:
  decode
  rebuild the remote SDP from the shared template
  setRemoteDescription(rebuilt SDP)

This is the same idea as the 2015 article, and the same idea as libp2p's WebRTC Direct v2. It also sidesteps the modern SDP munging rules: you never touch the local SDP, so Chrome has nothing to reject.

Packing the values

The ICE alphabet is 64 characters, so each character is exactly 6 bits:

A raw payload with no flags, no lengths, and no prefix is just:

pack6(ufrag) || pack6(pwd) || fingerprint || candidate
Measured sizes

With a generic header (flags + lengths + p: prefix), base64url text:

Mode SHA-256 SHA-1
full IPv4 85 chars / 62 B 69 chars / 50 B
last two octets 82 chars / 60 B 66 chars / 48 B
port only 80 chars / 58 B 64 chars / 46 B
trickle (no candidate) 77 chars / 56 B 61 chars / 44 B

Without the header/prefix (raw), assuming a fixed 4/24 profile:

Mode SHA-256 SHA-1
full IPv4 79 chars / 59 B 63 chars / 47 B
last two octets 76 chars / 57 B 60 chars / 45 B
port only 74 chars / 55 B 58 chars / 43 B
trickle 71 chars / 53 B 55 chars / 41 B

Every row in that table was verified with a real DataChannel ping/pong in Chrome, Safari, and Chrome ↔ Safari.

If you're sending binary over a WebSocket or DataChannel, use the byte counts. If you're stuck with text, use the character counts.


The 23-byte handshake

The fingerprint is the biggest remaining item: 32 bytes for SHA-256. If both sides already know each other's certificate fingerprint, it doesn't need to be on the wire at all.

Can you just hardcode a certificate in a static page? Not with the standard web API. I checked the API surface in Chrome 157:

new RTCCertificate()
// TypeError: Failed to construct 'RTCCertificate': Illegal constructor

JSON.stringify(cert)
// "{}"

structuredClone(cert)
// true

new RTCPeerConnection({ certificates: [{ getFingerprints: () => [] }] })
// TypeError: Failed to convert value to 'RTCCertificate'

RTCPeerConnection.generateCertificate() is the only factory. There is no import API. You cannot put a PEM private key in a static page and hand it to RTCPeerConnection.

What you can do is generate a certificate once per browser profile, store the RTCCertificate in IndexedDB, and reuse it:

async function getOrCreateCert() {
  const db = await openDb();
  let cert = await idbGet(db, 'dtls');
  if (!cert) {
    cert = await RTCPeerConnection.generateCertificate({
      name: 'ECDSA',
      namedCurve: 'P-256'
    });
    await idbPut(db, 'dtls', cert);
  }
  return cert;
}

const cert = await getOrCreateCert();
const pc = new RTCPeerConnection({ certificates: [cert] });
pc.createDataChannel('x');

RTCCertificate is structured-cloneable, so IndexedDB can store it. Chrome and Safari both keep the same fingerprint across page reloads. I did not separately test a full browser restart, but IndexedDB normally persists.

The first pairing still has to exchange the fingerprint somehow: a QR code, a pairing code, a config file, or a normal first connection. After that, both sides cache peerId -> fingerprint and omit it from the wire.

The payload becomes:

ufrag 3 B + pwd 18 B + port 2 B = 23 B

Or 31 characters as base64url.

That's the floor for standard browser WebRTC: 23 bytes per direction, under these assumptions:

You cannot get to zero with standard browser APIs, because ice-ufrag, ice-pwd, and the UDP port are random for every RTCPeerConnection. Only a custom ICE stack can fix those too.

A note on shared certificates

You might be tempted to put one certificate/private key on every device in a "trusted LAN". Please don't.

The sane model is one persistent certificate per device, with fingerprints provisioned or pinned out-of-band. That still gets you the 23-byte handshake, without a single shared secret.


Where does the IP come from?

If the wire only carries a port, the receiver needs the peer's IP from somewhere else.

The browser won't help: it hides local IPs behind mDNS. You need the signaling layer or the OS.

Best for a LAN: let the signaling server observe the source address.

function normalizeIp(addr) {
  if (!addr) return null;
  if (addr.startsWith('::ffff:')) return addr.slice(7);
  if (addr === '::1') return '127.0.0.1';
  return addr;
}

// HTTP: req.socket.remoteAddress
// ws:   req.socket.remoteAddress

Relay that peerIp alongside the signaling message. The receiver rebuilds:

a=candidate: 1 udp 1 <peerIp> <port> typ host

If the signaling server is in the cloud, it sees the peer's public NAT address, not the LAN IP. For a LAN-local signaling server (or direct peer-to-peer signaling), the source address is exactly what you want.

Other options:

This is why "IP from signaling" isn't cheating: the signaling connection already exists, and its source address is free metadata. The WebRTC payload can therefore be 55 bytes (SHA-256, port-only) or 23 bytes (pre-shared certificate). If you want a self-contained payload with the IP included, add 4 bytes for a full IPv4 address (or 1–2 bytes if you only send a subnet suffix).


Cross-browser notes

Chrome and Safari agreed on more than I expected:

The differences:

If you want to be conservative (Firefox, future browser changes), keep a=mid:0, a=sctp-port:5000, and a=setup:actpass/active, and use UDP/DTLS/SCTP webrtc-datachannel. That's about 348 bytes per direction — still tiny compared to a full SDP.


Security reality check

The small numbers are fun, but the assumptions matter:


The takeaway

If you only remember one number: 23 bytes per direction for a browser-to-browser DataChannel handshake on a LAN, with a persistent certificate and a pinned fingerprint.

If you need to be practical:

Situation Use
Chrome/Safari, LAN, both ends controlled raw payload + known IP + port-only: 55 B (SHA-256) / 74 chars
Same, with persistent certs 23 B / 31 chars
Must send SDP text, LAN minimal SDP + SHA-256: 279 B
Cross-browser, conservative add mid + sctp-port + setup: ~348 B
Across NAT srflx/relay candidate: at least 6 B candidate + 32 B fingerprint + 21 B ICE credentials

The 2015 article's 106-character answer is still in the right ballpark. What changed is not the information content; it's the dialect (sctpmap → sctp-port), the privacy defaults (mDNS), the SDP munging rules, and the fact that Safari now plays in the same minimal-SDP sandbox.

The most surprising part, to me, is that the browser's own certificate store and IndexedDB let you turn a per-connection DTLS identity into a per-device identity. That's what takes the handshake from 55 bytes to 23 bytes — and it's also the part that deserves the most security thought.

Comments