Debugging DTLS Handshake Failures
iceConnectionState says connected, the selected candidate pair is exchanging bytes in both directions, and dtlsState sits on connecting until the user closes the tab. This guide is part of the DTLS-SRTP Security & Encryption guide, and it addresses exactly one failure shape: a transport that clears ICE and then dies in the handshake. Five causes cover nearly every real instance — a fingerprint that does not match the presented certificate, a setup role collision, an expired certificate, a first flight too large for the path, and a middlebox that forwards STUN but discards DTLS — and each leaves a different signature in chrome://webrtc-internals and about:webrtc.
Context & Trade-offs
The reason this class of bug burns hours is that nothing in the JavaScript API tells you it happened. There is no dtlserror event. RTCPeerConnection.connectionState aggregates ICE and DTLS, so it stays on connecting rather than jumping to failed, and ICE itself is perfectly healthy — RFC 7675 consent refreshes keep flowing every few seconds, so the ICE layer reports green while the crypto layer is dead. In Chrome the aggregate only reaches failed when the DTLS transport exhausts its retransmissions, typically 15–30 s in. Users abandon a call that has not shown video in 3–5 s, so in practice you never see the terminal state in the wild; you see a support ticket that says “it just spins”.
The retransmission behaviour also disguises intermittent problems as permanent ones. A single lost flight costs 1 s before the retry, and the timer doubles, so three consecutive losses put you 7 s deep. If the path drops only the oversized flights and passes everything else, you get a connection that succeeds on Wi-Fi and fails on a corporate VPN, with identical SDP on both.
The five causes are easy to separate once you know what to sample. The decisive fields are dtlsRole on both ends, whether DTLS bytes ever leave the host, and whether anything comes back.
| Cause | dtlsRole (A / B) | DTLS bytes out | Distinguishing signal |
|---|---|---|---|
| Fingerprint mismatch | client / server | yes, both ways | fatal alert then dtlsState: 'failed' in 1–2 RTT |
Both sides active |
client / client | yes, both ways | each end logs an unexpected ClientHello |
Both sides passive |
server / server | none | zero DTLS bytes; state pinned at connecting |
| Expired certificate | client / server | yes, one way | alert 45 certificate_expired, or a constructor throw |
| MTU or middlebox | client / server | yes, one way | repeated identical ClientHello, nothing returns |
Note the fast failures at the top and the slow ones at the bottom. Fingerprint and role errors resolve within a round trip or two because someone sends an alert; MTU and middlebox errors have no error path at all, which is why they dominate the “stuck forever” reports. Work the checks in the order that eliminates the noisy causes first.
One caveat before you start collecting these fields. WebKit populates dtlsState reliably but has historically left localCertificateId and remoteCertificateId unset, so on Safari the fingerprint branch returns nothing and a missing certificate report proves only that Safari did not expose one. Fall back to parsing pc.remoteDescription.sdp there, and record the platform alongside the verdict so your dashboards do not fill with phantom lost flights from iOS.
Minimal Runnable Implementation
The probe below is what you attach to any connection you suspect. It samples getStats() on a 1 s interval, records the gap between ICE reaching connected and DTLS reaching connected, and if the gap exceeds a threshold it emits a verdict rather than a stack trace.
// Attach immediately after constructing the RTCPeerConnection, before setLocalDescription.
function watchDtls(pc, expectedRemoteFp, onVerdict, stallMs = 4000) {
let iceAt = null; // ms timestamp of ICE 'connected'
pc.addEventListener('iceconnectionstatechange', () => {
if (pc.iceConnectionState === 'connected' && iceAt === null) iceAt = performance.now();
});
const timer = setInterval(async () => {
const report = await pc.getStats();
const t = [...report.values()].find(r => r.type === 'transport');
if (!t) return;
if (t.dtlsState === 'connected') { clearInterval(timer); return; } // healthy, stop polling
if (iceAt === null || performance.now() - iceAt < stallMs) return; // still within budget
// --- ICE has been up for >stallMs and DTLS has not completed: classify it. ---
const localSetup = /a=setup:(\w+)/.exec(pc.localDescription?.sdp ?? '')?.[1];
const remoteSetup = /a=setup:(\w+)/.exec(pc.remoteDescription?.sdp ?? '')?.[1];
const sdpFp = /a=fingerprint:\S+\s+(\S+)/.exec(pc.remoteDescription?.sdp ?? '')?.[1];
const remoteCert = report.get(t.remoteCertificateId); // undefined on Safari
let cause = 'unknown';
// Both ends resolved to the same role, so nobody is the DTLS server (or nobody is the client).
if (localSetup && remoteSetup && localSetup === remoteSetup) cause = 'setup-role-collision';
// A cert arrived but its hash is not the one signaling promised: something rewrote the SDP.
else if (remoteCert && remoteCert.fingerprint?.toLowerCase() !== expectedRemoteFp.toLowerCase())
cause = 'fingerprint-mismatch';
// No cert at all after 4 s means the peer's first flight never landed here.
else if (!remoteCert && t.bytesReceived > 0) cause = 'flight-lost-mtu-or-middlebox';
clearInterval(timer);
onVerdict({
cause,
dtlsState: t.dtlsState, // 'new' | 'connecting' | 'failed'
dtlsRole: t.dtlsRole, // must be the OPPOSITE of the peer's value
localSetup, remoteSetup, // 'actpass' | 'active' | 'passive'
sdpFingerprint: sdpFp, // what the SDP claimed
statsFingerprint: remoteCert?.fingerprint ?? null, // what actually showed up
stalledForMs: Math.round(performance.now() - iceAt),
selectedPair: t.selectedCandidatePairId
});
}, 1000);
}
Two details make this worth running in production and not just on your laptop. t.bytesReceived > 0 with no remote certificate report is the exact fingerprint of a one-way DTLS path: STUN checks got through (that is where the bytes came from) but the peer’s ServerHello flight did not. And reporting localSetup/remoteSetup as raw strings, rather than a boolean, is what lets you spot a gateway that answered actpass — a value that is only legal in an offer, and one that browsers reject with Answerer must use either active or passive value for setup attribute. when they see it in an answer. Collisions produced by simultaneous offers are a signaling-layer problem, and the resolution pattern lives in Recovering from Glare in Offer Collisions.
Reproduction Steps & Debugging Log Patterns
- Open
chrome://webrtc-internalsbefore the call starts — it only records connections created after the tab is open — and tick Create a WebRTC-Internals dump so you keep the event log. - Reproduce, then find the peer connection’s
RTCTransport_0_1entry. ReaddtlsState,dtlsRole,tlsVersion,dtlsCipherandsrtpCipher. A stalled handshake showsdtlsState: connectingwithsrtpCipherempty andselectedCandidatePairChangesat 1. - Scroll the event log in the left column. A healthy transport logs
transportstatechange => connectingand thenconnectedwithin 30–80 ms oficeconnectionstatechange => connected; a stalled one logs the first and never the second. Interpreting the rest of that dump is covered in Reading chrome://webrtc-internals Dumps. - Restart Chrome with verbose native logging to get the reason rather than the symptom, then grep the log for
dtls. - Re-run the same case in Firefox with
about:webrtcopen; its per-connection Connection Log timestamps every transport state change, andabout:loggingwith themtransportmodule gives you the NSS-level alert code. Firefox’s diagnostics for the layer underneath are described in Diagnosing ICE Failures with Firefox about:webrtc.
; Chrome — verbose libwebrtc logging, written to chrome_debug.log in the user data dir
; --enable-logging=stderr --v=1 --vmodule=dtls_transport=3,openssl_stream_adapter=3
;
; Firefox — set these before launch; MOZ_LOG_FILE receives the DTLS and ICE layers
; MOZ_LOG=signaling:5,mtransport:5
; MOZ_LOG_FILE=/tmp/moz-webrtc.log
;
; coturn — confirm the relay is not itself the middlebox eating large datagrams
verbose ; per-allocation logging, including dropped oversized payloads
no-multicast-peers ; keep the relay from forwarding to unexpected destinations
max-allocate-lifetime=3600 ; long enough that the allocation outlives a slow handshake
The log lines you are matching against are short and specific. A healthy Chrome run emits DtlsTransport[0|1|__]: Started DTLS handshake followed within one or two round trips by DtlsTransport[0|1|__]: DTLS handshake complete. Everything else is a named failure:
// Fingerprint mismatch — the cert arrived and was rejected locally
// dtls_transport.cc: Peer certificate does not match fingerprint from the remote description
// -> dtlsState goes 'connecting' -> 'failed' in under 200 ms; a fatal alert goes out
// Role collision, both ends active — each sees a ClientHello where a ServerHello belongs
// openssl_stream_adapter.cc: Error from SSL_do_handshake: 1
// -> SSL_ERROR_RX_UNEXPECTED_HANDSHAKE on the peer; both ends report dtlsRole 'client'
// Expired certificate on the media server
// openssl_stream_adapter.cc: SSLv3 alert certificate expired (fatal alert 45)
// -> in Chrome, reusing a cached RTCCertificate past 30 days throws at construction:
// InvalidAccessError: Failed to construct 'RTCPeerConnection': Expired certificate(s).
// Flight lost to MTU or a middlebox — the quietest of the five
// dtls_transport.cc: Started DTLS handshake <- logged once
// (nothing else, ever; the retransmits are inside BoringSSL and are not logged at v=1)
// -> bytesSent on the transport creeps up ~1.2 kB at a time; bytesReceived stays flat
That last pattern is the one worth internalising: bytesSent climbing in roughly 1.2 kB steps at 1 s, 3 s, 7 s while bytesReceived is frozen means your flights are leaving and nothing is coming back. Sizing is the usual culprit. Chrome clamps its own DTLS records to about 1200 bytes so browser-to-browser handshakes almost never fragment, but a media server presenting an RSA-2048 certificate can push a single flight past 1300 bytes, and a VPN or PPPoE path with a 1400-byte MTU turns that into IP fragments that a stateful firewall silently drops.
Middleboxes are the hardest of the five to prove, because from the endpoint they are indistinguishable from ordinary loss. The tell is selectivity: STUN binding requests and consent refreshes are small, well-known and recognised by every deep-packet-inspection engine, so they pass, while a datagram whose first byte is 22 and whose length is over a kilobyte hits an “unknown UDP application” rule and is dropped. Enterprise gateways configured to allow only recognised UDP protocols behave exactly this way, and so do a few consumer routers with aggressive ALG features. If a capture on the sending host shows the flight leaving and a capture on the receiving host shows it never arriving, you have located the device by elimination even if you cannot see it.
The cheapest confirmation is to force the connection through a relay: set iceTransportPolicy: 'relay' with a TURN server reachable over TLS on 5349 or TCP 443. TCP re-segments, so an oversized flight that dies over UDP sails through, and a success there is close to proof that the direct path has an MTU or filtering problem rather than a certificate one. Making that path reliable in locked-down networks is covered in Forcing TURN over TCP 443 on Locked-Down Networks.
Common Implementation Mistakes
- Reading
dtlsRoleon only one end. A role collision is invisible from a single peer:clientlooks perfectly normal until you learn the other side also saysclient. Ship both values to your telemetry and alert when they match. - Persisting an
RTCCertificatepast its expiry.RTCCertificateis structured-cloneable, so caching one in IndexedDB is a legitimate way to keep a stable identity — but Chrome defaults to a 30-day lifetime and clampsexpiresto 365 days, and the constructor throwsExpired certificate(s).afterwards. Checkcertificate.expiresagainstDate.now()before reusing it, and regenerate rather than catching the throw. - Blaming the certificate when there is no certificate. If
remoteCertificateIdnever resolves, the peer’s flight never arrived, and no amount of cert regeneration will help. Distinguish “rejected” from “absent” before you touch key material. - Treating a fingerprint mismatch as a transient network fault and retrying. A mismatch is either a bug in your SDP handling or an active attacker. Retrying an attacker’s connection until it succeeds is worse than failing; the correct handling of the trust boundary is in Verifying DTLS Fingerprints to Prevent MITM.
- Calling
restartIce()to fix it. An ICE restart moves media to a new candidate pair but keeps the existing DTLS association, so a handshake that failed for role, fingerprint or certificate reasons fails again identically — and you have burned one of your three restart attempts. Only the MTU and middlebox causes can be helped by a new path, and even then only if the new path is a relay.
FAQ
Why does connectionState stay connecting instead of going to failed?
Because connectionState is the aggregate of every transport, and the ICE transport genuinely is connected. Chrome only promotes the aggregate to failed once the DTLS transport gives up, which takes 15–30 s of backoff. Poll transport.dtlsState yourself if you want to fail fast at 4–5 s.
Both ends report dtlsRole: 'server' and nothing is on the wire. What happened?
Your answerer replied a=setup:passive to an offer that was itself rewritten to passive — usually by an SFU or SBC that assumes it is always the server. Neither side sends a ClientHello, so bytesSent for DTLS stays at zero. Force the browser side to active in the answer and re-offer.
The handshake succeeds over TURN but never directly. Is that a DTLS bug?
No, it is almost always path MTU or a filtering middlebox. TURN over TCP re-segments the flight so size stops mattering, and TURN over UDP often takes a different route that has no DPI device on it. Confirm by comparing DTLS bytesReceived on the direct attempt — a flat zero against a climbing bytesSent is the signature.
Related: return to DTLS-SRTP Security & Encryption for the handshake mechanics behind these symptoms, then read End-to-End Encryption with Insertable Streams if the media server must never see plaintext, and Disconnected vs Failed ICE States when the layer below is the one that broke.