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.

Triage order for a stalled DTLS handshake A four-step decision tree. Starting from a connection where ICE is connected but the DTLS state is stuck, each check branches right to a named cause when it fails and down to the next check when it passes: certificate fingerprint against the SDP, exactly one side using setup active, certificate validity dates, and whether a relay-only TURN over TCP attempt succeeds. If nothing matches, the final step is a packet capture. ICE connected, dtlsState stuck no error event — the state never advances Stats cert == SDP a=fingerprint? compare getStats to remoteDescription no Fingerprint mismatch SDP rewritten, or the cert was regenerated ok Exactly one side setup:active? read a=setup in local and remote SDP no Role collision both actpass, both active, or both passive ok Every certificate still in date? server PEM notAfter; 30-day browser certs no Expired certificate fatal alert 45, or a throw at construction ok Works with relay-only TURN/TCP? iceTransportPolicy relay, TLS on 5349 yes MTU or middlebox STUN passes, large DTLS datagrams do not no Nothing matched — capture the nominated pair Wireshark filter dtls; count how many identical ClientHellos leave the host
Run the checks top to bottom: the fast-failing causes announce themselves, the silent ones are what remain.

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.

Retransmission timeline for a lost first flight A time axis from zero to sixteen seconds marks the initial ClientHello and four retransmissions at one, three, seven and fifteen seconds, following an exponential backoff with a one second base. A dashed marker at four seconds shows where a typical user abandons the call. Two status bars below the axis show that the DTLS state stays on connecting with no callback while the ICE connection state stays connected throughout. Flight retransmission after a lost ClientHello 1 s base, doubling each attempt — none of it surfaces to your code each stem re-sends the entire flight, byte for byte 0 s 1 s 3 s 7 s 15 s hello #1 retry +1 s retry +2 s retry +4 s retry +8 s user gives up dtlsState: connecting — no callback, no exception, no alert iceConnectionState: connected — consent refresh keeps it green
The backoff schedule outlives the user's patience, and both states the app can observe stay unchanged the whole time.

Reproduction Steps & Debugging Log Patterns

  1. Open chrome://webrtc-internals before 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.
  2. Reproduce, then find the peer connection’s RTCTransport_0_1 entry. Read dtlsState, dtlsRole, tlsVersion, dtlsCipher and srtpCipher. A stalled handshake shows dtlsState: connecting with srtpCipher empty and selectedCandidatePairChanges at 1.
  3. Scroll the event log in the left column. A healthy transport logs transportstatechange => connecting and then connected within 30–80 ms of iceconnectionstatechange => 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.
  4. Restart Chrome with verbose native logging to get the reason rather than the symptom, then grep the log for dtls.
  5. Re-run the same case in Firefox with about:webrtc open; its per-connection Connection Log timestamps every transport state change, and about:logging with the mtransport module 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.

Flight size against path MTU A byte ruler from zero to fifteen hundred bytes with a dashed line marking a path MTU of twelve hundred and eighty bytes. A six hundred and twenty byte ECDSA flight sits well inside the limit. A thirteen hundred and forty byte RSA flight crosses it and is dropped. The same flight split into a twelve hundred byte fragment and a one hundred and eighty byte fragment fits. One flight, one datagram: where the handshake dies 0 B 500 1000 1500 path MTU 1280 B ECDSA flight — 620 B one datagram, no fragmentation RSA-2048 flight — 1340 B in one datagram crosses the line, IP-fragments, gets black-holed same flight with the DTLS record size clamped to 1200 B: fragment 1 — 1200 B, fits frag 2 — 180 B A TURN relay adds ~36 B of channel overhead — subtract it before you size the flight.
Anything that pushes a single flight past the path MTU turns a crypto handshake into a silent packet-loss problem.

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

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.