Minimal WebRTC handshake
Posted | stdin
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:
- ICE gives you a UDP path.
- DTLS gives you encryption and identity.
- 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:
ufrag: 4 characters (24 bits of randomness)pwd: 24 characters in Chrome/Safari (144 bits)- DTLS fingerprint: 32 bytes for SHA-256, 20 bytes for SHA-1
- candidate: 4 bytes IPv4 + 2 bytes port (or less, if the IP is known)
That's the whole handshake, information-theoretically.
The test setup
I used:
- Chrome/Edge 157 (Chromium 157) headless, driven by Playwright. I used Microsoft Edge as the Chromium stand-in; the SDP and API behavior are the same as Chrome 157.
- Safari 26.2 on macOS, driven by AppleScript + a local page. No WebDriver permissions needed.
- A local Node HTTP server for signaling.
- A DataChannel
ping/pongecho to prove the connection actually works. - Two topologies: two
RTCPeerConnections in the same page, and Chrome page ↔ Safari page.
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
v=must be first.o=must have exactly six space-separated fields. The values are not validated.s=must exist and be non-empty.t=must exist.- Lines must be in RFC order. Blank lines are fatal. The last line must end with a newline.
- LF and CRLF both work. LF saves one byte per line.
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=mid— Chrome and Safari accept a single m-line without it; Chrome visibly synthesizesa=mid:0when parsing the remote SDP. (JSEP says include it; cross-browser code should.)a=sctp-port— defaults to 5000.a=setup— offer defaults toactpass, answer toactive.a=group:BUNDLE— optional with the default balanced policy.c=— ICE never uses it.a=ice-options:trickle— not required foraddIceCandidate.a=max-message-size— defaults to 64 KiB.a=end-of-candidates— ignored by Chrome.a=msid-semantic,a=extmap-allow-mixed— irrelevant for data.
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:
a=sctp-portcannot be combined withDTLS/SCTP x. If you includea=sctp-port, the m-line must bem=application 9 UDP/DTLS/SCTP webrtc-datachannel, otherwise you getsctp-port attribute can't be used with sctpmap.- Safari uses
.localcandidates 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. - 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:
- 4-char ufrag → 3 bytes
- 24-char pwd → 18 bytes
- SHA-256 fingerprint → 32 bytes
- SHA-1 fingerprint → 20 bytes
- IPv4 candidate → 4 bytes IP + 2 bytes port
- port-only → 2 bytes
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:
- Both sides have a persistent certificate and a cached peer fingerprint.
- The LAN IP is known from signaling.
- The SDP template,
mid,sctp-port,setup, and the role are shared/defaulted. - ICE credentials are still generated by the browser, per connection.
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.
- Anyone who can read the page or the device can impersonate every device.
- One compromised device invalidates the whole identity model.
- You can't distinguish devices or revoke one without breaking all of them.
- A copied browser profile may clone the identity anyway.
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:
- Direct LAN signaling: if one browser connects to the other over TCP/WebSocket, the remote address is the peer's LAN IP.
- Same machine: use
127.0.0.1. - mDNS: don't guess; send the
.localcandidate and let the browser resolve it. Costs ~30–42 characters. - STUN srflx: gives the address the STUN server sees; useful across NAT, not for discovering a LAN IP.
- TURN relay: the relay IP is the TURN server's IP (known), only the relay port is dynamic. The "IP known, port only" trick works there too, and it works across NAT.
- Subnet guessing: if both peers are on the same /24, send the last octet (1 byte) + port (2 bytes). Same /16: send two octets (2 bytes) + port. Unknown: send the full IPv4 (4 bytes) + port. Scanning 254 addresses is not recommended: slow, noisy, and often blocked.
- Peer-reflexive: for browser-to-server, the server knows its own IP and learns the client's address from the first STUN binding request. The client can omit its candidate entirely. For browser-to-browser, at least one side must know the other's address to send the first packet.
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:
- Both accept the same minimal
m=application 9 DTLS/SCTP xtemplate. - Both accept missing
a=mid,a=setup,c=, anda=group:BUNDLEfor a single m-line. - Both accept SHA-1 fingerprints (but don't use SHA-1).
- Both can persist
RTCCertificatein IndexedDB. - Both connect with 241 B SDP (SHA-1) and 279 B (SHA-256) in both directions.
The differences:
- Safari's host candidate is
.local; Chrome's can be raw IP with the test flag. a=sctp-portrequiresUDP/DTLS/SCTP webrtc-datachannelin Safari;DTLS/SCTP x+a=sctp-portis a parse error.- Safari's
getStats()exposesbase64Certificate, which is how I computed SHA-1 fingerprints.
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:
- SHA-1 is not acceptable in production. It saves 12 bytes of binary / 36 bytes of text and buys you a collision attack. Use SHA-256.
- The fingerprint is the trust anchor. If signaling is unauthenticated, an attacker can substitute it and MITM the DTLS connection. Use WSS/HTTPS, or sign the payload end-to-end.
- Don't modify the local SDP's
ice-ufrag/ice-pwd. Chrome 137+ rejects it. Generate locally, read the values, rebuild only the remote SDP. - Pre-shared fingerprints are certificate pinning. Protect the certificate store, rotate before
expires, and authenticate the first pairing. - mDNS is a privacy feature. You can't turn it off from a web page. For a compact LAN payload, use the signaling layer's known IP; for the open internet, use STUN/TURN.
- A shared private key is not a shortcut. Per-device certificates with pinned fingerprints give you the same 23 bytes without a single point of total compromise.
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