Skip to content
WebRTC.wiki

WebRTC DTLS handshake failed: how to find out why

ICE says connected, then connectionState goes to failed. That is the DTLS handshake, and RTCDtlsTransport's error event names the exact cause.

5 min readupdated

Connectivity checks passed. iceConnectionState says connected. Then connectionState goes to failed and no media ever arrives.

Nothing is wrong with your ICE. When ICE connects and the peer connection still fails, the DTLS handshake failed. The path is open and packets flow in both directions, but the two ends could not agree on the DTLS session that keys SRTP. This bug is annoying because the state machine everyone watches has already reported success.

The browser knows exactly what went wrong. You have to listen on the right object to hear it.

Read the error from RTCDtlsTransport

RTCPeerConnection gives you three state machines and people generally watch the wrong one. iceConnectionState covers connectivity only. connectionState is an aggregate, so it tells you something failed without telling you what. The one you want is on the transport itself:

const transport = pc.getSenders()[0].transport;
transport.addEventListener('statechange', () => console.log(transport.state));
transport.addEventListener('error', (e) => {
  console.log(e.error.errorDetail, e.error.receivedAlert, e.error.sentAlert);
});

RTCDtlsTransport.state moves through new, connecting, connected, and lands on failed or closed. The error event is the part almost nobody uses, and it is the most useful diagnostic in the entire API. Its errorDetail is either "fingerprint-failure" or "dtls-failure", which splits the problem space cleanly in two, and receivedAlert and sentAlert carry the actual DTLS alert codes off the wire.

fingerprint-failure means the handshake worked and the certificate was not the one you promised. Everything else is dtls-failure.

Note that with BUNDLE there is one transport for the whole session, so any sender or receiver gives you the same object.

Quick diagnosis

what you see likely cause
errorDetail is fingerprint-failure the SDP and the certificate are out of sync
stuck in connecting, no alert either way both sides chose passive, or the ClientHello never arrived
healthy call dies on renegotiation the answerer changed its DTLS role mid-session
constructor throws InvalidAccessError a stored RTCCertificate is past expires
connects, but sometimes takes seconds a lost handshake fragment waiting on the retransmission timer
fails on one corporate network only a middlebox dropping DTLS 1.3

Each row has its own section below.

Fingerprint mismatch: the certificate does not match the SDP

The certificates in WebRTC are self-signed and prove nothing on their own. What binds one to the right peer is the a=fingerprint line in the SDP, carried through your signalling channel. If the hash of the certificate presented in the handshake does not match the hash in the SDP, the connection dies on purpose.

In practice this almost always means the SDP and the certificate came from different moments in time:

  • The server regenerated its certificate between building the offer and receiving the handshake. Restarts during a deploy do this beautifully.
  • Something in the middle rebuilt the SDP. A signalling gateway that parses and re-serializes will drop or rewrite the fingerprint line if it does not understand it.
  • You cached an offer. An SDP has a shelf life measured in seconds.
  • Multiple m= sections carry different fingerprints while a=group:BUNDLE says they all share one transport.

DTLS role conflicts: actpass, active and passive

RFC 8842 is short and worth reading in full, but the rule that matters is this: the offerer MUST send a=setup:actpass (section 5.2), and the answerer picks active or passive (section 5.3). Active means you send the ClientHello.

Two ways to break it. If both ends conclude they are passive, nobody starts, and the transport sits in connecting until something else times out. If both conclude they are active, both send a ClientHello and what happens next depends on how forgiving each stack is.

Home-rolled servers are where this goes wrong, usually by writing a fixed a=setup:passive into an offer instead of actpass. That is legal-looking and works right up until it meets a client that also wants to be passive.

The subtler version bites on renegotiation. Section 5.5 says subsequent offers still use actpass, and the answerer must not change the roles already negotiated for an existing association. A server that re-picks its role on every renegotiation will tear down a perfectly healthy DTLS session in the middle of a call, and it will look like a random disconnect.

Worth saying plainly: the DTLS role has nothing to do with the ICE role. Your ICE lite server is the controlled ICE agent and can still be the DTLS client.

Expired certificates from generateCertificate()

RTCPeerConnection.generateCertificate() produces a certificate that expires 30 days later. That is not a suggestion, it is the default value in the spec, and RTCCertificate is deliberately storable in IndexedDB so applications can keep a stable identity across sessions.

So people cache it. On day 31 the RTCPeerConnection constructor throws InvalidAccessError before any handshake happens at all. It gets reported as “DTLS broke” because that is what the user sees, and it is nothing of the kind. If you cache certificates, check certificate.expires before you use one.

Slow handshakes: fragmentation and MTU

DTLS splits handshake messages to fit the path MTU, and the flight carrying the certificate is the big one. Lose a single fragment and the entire flight is retransmitted on the timer from RFC 6347 section 4.2.4.1: one second, then two, then four, doubling toward sixty.

This is the source of the complaint that connections “sometimes take five seconds”. They are not slow. They lost one packet during the handshake and paid a retransmission timer for it.

Anything that shrinks the usable MTU makes it more likely. A TURN allocation costs you 4 bytes per packet with ChannelData, 36 with a Send indication, more again over TLS. And the certificate itself is under your control: browsers default to ECDSA P-256, which produces a small certificate message, while a server configured with RSA 2048 produces a much larger one and fragments where the browser would not have.

DTLS 1.3 and middleboxes

Browsers negotiate DTLS 1.3 by default now. Firefox has shipped it on release since version 127, and the other engines followed. An older server stack negotiates down to 1.2 without drama, so version alone is rarely the problem.

Middleboxes are. A device doing deep packet inspection with a pattern matcher written against the 1.2 handshake can silently drop 1.3. The signature of this is a handshake that fails on one corporate network and works everywhere else, with no alert in either direction because nothing was ever answered.

When there is no DTLS alert at all

If receivedAlert is empty and the state just sits in connecting, then no DTLS response ever arrived and the problem is below DTLS. Capture on the server. You are looking for whether the ClientHello arrived at all, which decides whether you are debugging your role logic or your network.

Everything else, the browser already knows. It just puts the answer on an event handler nobody registers.