DTLS-SRTP Security & Encryption: Handshakes, Keying and Fingerprint Verification

Every WebRTC session encrypts its media, and it does so without a public certificate authority anywhere in the path: two self-signed certificates meet over UDP, prove they match hashes that arrived through your signaling channel, and export the key material that keys SRTP for the rest of the call. This guide is part of the WebRTC Protocol Stack & Signaling Servers guide, and it walks the whole chain — certificate generation, the a=fingerprint binding, the DTLS handshake over the nominated candidate pair, RFC 5764 key export, and the role negotiation that decides who speaks first. The implementation goal is a session where you can point at a getStats() report and state, with evidence, which certificate keyed which direction and with which cipher.

The first thing to fix in your mental model is that DTLS is not a separate connection. Once ICE nominates a candidate pair, that single UDP 5-tuple carries STUN connectivity checks, DTLS handshake records, SCTP-in-DTLS data, and SRTP media simultaneously. They are told apart by the value of the first byte of each datagram, using the ranges RFC 7983 fixed: 0–3 is STUN, 20–63 is DTLS, 128–191 is SRTP or SRTCP. That demultiplexer is why a firewall rule or relay that mangles one class of traffic breaks the others, and why “media works but the data channel never opens” is almost never a data-channel bug.

First-byte demultiplexing on the nominated candidate pair A single UDP socket on the ICE-nominated pair feeds a first-byte demultiplexer defined by RFC 7983, which routes datagrams to three consumers: STUN connectivity checks for byte values 0 to 3, DTLS records for 20 to 63, and SRTP or SRTCP for 128 to 191. One UDP socket ICE-nominated pair src/dst ip:port First-byte demux RFC 7983 STUN — byte 0..3 connectivity checks, consent refresh DTLS — byte 20..63 handshake records, then SCTP payloads SRTP / SRTCP — byte 128..191 keyed from the DTLS exporter, never by DTLS one 5-tuple, three protocols, zero extra ports
STUN, DTLS and SRTP share the nominated pair and are separated purely by the first byte of each datagram.

Note the last line of that diagram: media is not sent inside DTLS. DTLS is only the key-agreement channel. Once the handshake completes, both sides run an exporter over the negotiated master secret to produce SRTP keys, and RTP packets are protected directly with those keys and sent as bare SRTP on the same socket. Data channels are the exception — SCTP genuinely rides inside the DTLS record layer, which is why the Data Channels & SCTP transport inherits DTLS framing costs that media does not.

Step 1 — Generate and pin a certificate

By default new RTCPeerConnection() generates a throwaway self-signed certificate for you. That is fine for a demo and wrong for production, because you get no control over the key type, no control over lifetime, and — critically — no ability to know the local fingerprint before the offer exists. Generate the certificate explicitly with the static RTCPeerConnection.generateCertificate() and hand it to the constructor.

The key type choice is not cosmetic. ECDSA P-256 keygen completes in single-digit milliseconds on a mid-range phone; RSA-2048 keygen routinely costs 100–400 ms on the same hardware and occasionally spikes past a second, which is a visible stall if you generate it on the click that starts a call. ECDSA also shrinks the handshake: a P-256 certificate is roughly 400–500 bytes on the wire versus 800–1000 bytes for RSA-2048, and on a lossy path a smaller flight means fewer fragments to lose and retransmit. Every modern browser defaults to ECDSA P-256; you should only reach for RSA when a legacy SIP gateway or an old media server refuses ECDSA cipher suites.

ECDSA P-256 versus RSA-2048 for WebRTC certificates A five-row comparison matrix. Rows compare key generation time, certificate size on the wire, negotiated cipher suite, mobile CPU and battery impact, and legacy gateway interoperability for ECDSA P-256 against RSA-2048. Property ECDSA P-256 RSA-2048 Key generation on mobile 1–5 ms 100–400 ms Certificate bytes in flight ~450 B, single fragment ~900 B, may fragment Typical cipher suite ECDHE_ECDSA_AES128_GCM ECDHE_RSA_AES128_GCM Browser default since 2017 yes, all engines opt-in only Pick RSA only when a legacy SIP or SBC endpoint rejects ECDSA suites.
Certificate key type decides handshake cost, flight size and which cipher suite the peers can agree on.

Generate once per identity, cache the RTCCertificate object, and reuse it across peer connections for the same user session. Reuse is what makes fingerprint pinning meaningful: if the fingerprint changes on every connection, a client cannot detect that a proxy substituted its own key, because there is no stable value to compare against.

// Generate once per session and cache. ECDSA P-256 is ~100x cheaper than RSA-2048 to create.
const certificate = await RTCPeerConnection.generateCertificate({
  name: 'ECDSA',
  namedCurve: 'P-256'
  // Chrome clamps `expires` to a maximum of 365 days; the default is 30 days.
  // A short lifetime is fine — the cert is only a key carrier, never a trust anchor.
});

// Read the fingerprint BEFORE any offer exists, so you can publish it out of band.
for (const fp of certificate.getFingerprints()) {
  // { algorithm: 'sha-256', value: '4a:ad:6b:...:9c' } — lowercase, colon separated
  console.log('local fingerprint', fp.algorithm, fp.value);
}

const pc = new RTCPeerConnection({
  certificates: [certificate],           // MUST be passed at construction; it is read-only after
  iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
  bundlePolicy: 'max-bundle'             // one DTLS association for all m-lines, not one per section
});

Two constraints bite here. certificates is only honoured in the constructor — assigning it later is silently ignored, and there is no API to rotate a certificate on a live RTCPeerConnection. And bundlePolicy: 'max-bundle' matters for security work as much as for performance: without BUNDLE each m-section can negotiate its own DTLS association with its own fingerprint, so “verify the fingerprint” becomes “verify every fingerprint in the SDP” and it is easy to check the audio one while video is keyed by an attacker.

Step 2 — Wire fingerprints through signaling

The a=fingerprint attribute is the entire trust model. It carries a hash — SHA-256 in every current implementation — of the DER encoding of the certificate the peer will present. Because the certificate is self-signed, nothing about it is trustworthy on its own; what makes the handshake safe is that the hash travelled over a channel you already authenticated, typically an authenticated WebSocket to your own server. DTLS therefore proves possession of the private key matching the fingerprint you were told to expect, and nothing more.

The companion attribute is a=setup, which decides the DTLS roles. An offerer sends a=setup:actpass, meaning “I will be client or server, you choose”. The answerer replies a=setup:active to become the DTLS client (it sends the first ClientHello) or a=setup:passive to become the server. Answering active is almost always right: the answerer already has the offerer’s fingerprint and candidates, so it can start the handshake the moment the first candidate pair is nominated, instead of idling until the offerer decides to.

Where fingerprint and setup live in the answer SDP An answer SDP excerpt in a panel on the left, with three callouts on the right explaining the UDP slash TLS slash RTP slash SAVPF profile token, the SHA-256 fingerprint attribute that binds signaling identity to the media keys, and the setup active attribute that makes this peer the DTLS client. answer SDP (excerpt, max-bundle) v=0 a=group:BUNDLE 0 1 m=audio 9 UDP/TLS/RTP/SAVPF 111 a=mid:0 a=ice-ufrag:8Fj2 a=fingerprint:sha-256 4A:AD:6B:..:9C a=setup:active a=rtcp-mux m=video 9 UDP/TLS/RTP/SAVPF 96 a=mid:1 Profile says DTLS-SRTP SAVPF, not AVPF — refuse plain RTP SHA-256 of the DER cert binds signaling identity to media keys setup:active = DTLS client this peer sends the first ClientHello
One session-level fingerprint under max-bundle, plus the setup attribute that assigns DTLS client and server roles.

There is a placement subtlety that trips up anyone writing an SDP parser. a=fingerprint is legal at both the session level and inside each m-section, and the media-level value overrides the session-level one for that section. Browsers under max-bundle emit a single session-level fingerprint and repeat it per section, but SIP gateways and older media servers frequently emit differing per-section values because they genuinely run one DTLS association per m-line. So “read the first fingerprint you find” is a bug: it validates the audio transport and lets an entirely different key protect video. Collect them all, and reject the session if they are not identical when you expect bundling.

Your signaling layer should carry the fingerprint as a first-class field alongside the SDP, not only inside it, and compare the two before applying the remote description. That comparison is what turns a passive “the browser will check it anyway” into an active MITM detection: the browser only checks that the presented certificate matches the fingerprint in the SDP it was given, so an attacker who can rewrite SDP in transit simply rewrites both. The full threat model, including out-of-band short authentication strings, is covered in Verifying DTLS Fingerprints to Prevent MITM.

// Extract every fingerprint the remote SDP declares — session level plus per m-section.
function fingerprintsFromSdp(sdp) {
  const out = [];
  for (const line of sdp.split(/\r\n|\n/)) {
    const m = /^a=fingerprint:(\S+)\s+(\S+)/.exec(line);
    if (m) out.push({ algorithm: m[1].toLowerCase(), value: m[2].toLowerCase() });
  }
  return out;
}

// The signaling envelope carries the expected value, signed/authenticated by your server.
function applyRemote(pc, envelope) {
  const declared = fingerprintsFromSdp(envelope.sdp.sdp);
  const expected = envelope.peerFingerprint.toLowerCase(); // from the authenticated channel
  // Reject weak digests outright: sha-1 fingerprints are still legal SDP and still parsed.
  if (declared.some(f => f.algorithm !== 'sha-256' && f.algorithm !== 'sha-384')) {
    throw new Error('weak fingerprint digest offered');
  }
  // Every m-section must be keyed by the identity we authenticated, not just the first.
  if (!declared.length || !declared.every(f => f.value === expected)) {
    throw new Error('fingerprint mismatch — refusing to negotiate');
  }
  return pc.setRemoteDescription(envelope.sdp); // only now is it safe to proceed
}

Server-side endpoints need the same discipline expressed in configuration rather than code. A media server keeps a long-lived certificate on disk, and the fingerprint it publishes in its answers must be derived from that exact file:

; media server DTLS section — the certificate is long-lived, unlike a browser's
[certificates]
cert_pem = /etc/webrtc/dtls-cert.pem   ; ECDSA P-256, self-signed, no CA chain needed
key_pem  = /etc/webrtc/dtls-key.pem    ; 0600, owned by the media process user
rotate_days = 90                       ; regenerate quarterly; publish the new fingerprint first

[dtls]
min_version = 1.2                      ; refuse DTLS 1.0; it is deprecated and still offered by old SBCs
srtp_profiles = SRTP_AEAD_AES_128_GCM,SRTP_AES128_CM_HMAC_SHA1_80  ; order = preference
setup_role = passive                   ; the server answers passive so browsers open the handshake

Step 3 — The handshake and SRTP keying

The handshake begins only after ICE nominates a pair — before that there is nowhere to send it. DTLS 1.2 runs six flights, but the ones that cost you wall-clock time are the round trips. The server answers the first ClientHello with a stateless HelloVerifyRequest carrying a cookie, forcing the client to repeat its hello with the cookie echoed back; this is DTLS’s amplification defence and it costs one extra round trip. The full exchange therefore settles in roughly 2 RTT after nomination — about 30–60 ms on a regional path, and noticeably more when the pair is a TURN relay, since relaying adds 20–40 ms one-way to every flight.

The critical extension is use_srtp, sent in the ClientHello and echoed in the ServerHello. It carries the list of SRTP protection profiles the client supports, and the server’s echo picks exactly one. If the extension is absent from either side, the handshake still succeeds but no SRTP keys are ever exported — you get a working DTLS tunnel and permanently silent media, a failure that looks nothing like a security error in the logs.

DTLS 1.2 handshake flights and SRTP key export A sequence diagram with two lifelines. The active peer sends ClientHello, receives HelloVerifyRequest with a cookie, resends ClientHello with the cookie and the use_srtp extension, receives ServerHello with certificate, sends its own certificate and key exchange with Finished, and receives ChangeCipherSpec and Finished. A final box shows the exporter producing sixty bytes of keying material split into client and server keys and salts. DTLS client a=setup:active (answerer) DTLS server a=setup:passive (offerer) ClientHello (flight 1) HelloVerifyRequest + cookie ClientHello + cookie + use_srtp list ServerHello + Certificate + use_srtp pick Certificate + KeyExchange + Finished ChangeCipherSpec + Finished both sides verify peer cert hash == a=fingerprint mismatch here fails the connection, not the handshake Exporter "EXTRACTOR-dtls_srtp" over the master secret 60 bytes: client key 16B | server key 16B | client salt 14B | server salt 14B
Two round trips of DTLS, then a pseudorandom-function export that yields both directions of SRTP keys.

Key export is the part people get wrong when reading a packet capture and expecting to see keys. Nothing key-shaped is ever transmitted. Both endpoints independently run the TLS exporter defined in RFC 5705 with the label EXTRACTOR-dtls_srtp over the negotiated master secret, and each takes the same 60 bytes for the classic SRTP_AES128_CM_HMAC_SHA1_80 profile: a 128-bit key and 112-bit salt for the client-to-server direction, then the same pair for server-to-client. The DTLS client role always claims the first key/salt pair for its outbound SRTP, which is why the setup attribute silently determines the direction mapping. Choose SRTP_AEAD_AES_128_GCM where both ends support it — the AEAD profiles authenticate the payload and header in one pass and exercise AES-NI, whereas the SHA-1 HMAC profiles cost more CPU per packet at high packet rates.

DTLS 1.3 collapses this. The HelloVerifyRequest round trip is replaced by the HelloRetryRequest/cookie mechanism only when the server actually needs anti-amplification, so the common case is a single round trip, cutting handshake time roughly in half and removing one opportunity for a lost flight to trigger the retransmission timer. Chrome has been shipping DTLS 1.3 progressively and negotiates down to 1.2 automatically, so you should read tlsVersion from stats rather than assume. Whichever version runs, the retransmission behaviour is the same shape and matters more than the version: DTLS starts a 1 s retransmit timer per flight and doubles it, so a single lost ClientHello costs a full second before the retry, and three consecutive losses put you 7 s into a connection that looks hung. That timer is the reason a marginal path produces “connects sometimes, slowly” rather than a clean failure — the diagnostic sequence for that is in Debugging DTLS Handshake Failures.

Step 4 — Verification with getStats() and browser tooling

You verify DTLS from the transport report and the two certificate reports it points at. This is the only place the browser tells you the negotiated cipher, the SRTP profile, and the actual fingerprints in use — the SDP tells you what was claimed, and stats tell you what was agreed.

// Poll at 1 s intervals alongside your media stats; DTLS fields settle once and stay put.
async function auditSecurity(pc, expectedRemoteFingerprint) {
  const report = await pc.getStats();
  const transport = [...report.values()].find(r => r.type === 'transport');
  if (!transport) return null;

  const local = report.get(transport.localCertificateId);   // may be undefined on Safari
  const remote = report.get(transport.remoteCertificateId);

  const audit = {
    dtlsState: transport.dtlsState,       // 'connected' is the only healthy terminal value
    tlsVersion: transport.tlsVersion,     // Chrome reports hex: 'FEFD' = DTLS 1.2, 'FEFC' = 1.3
    dtlsCipher: transport.dtlsCipher,     // e.g. 'TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256'
    srtpCipher: transport.srtpCipher,     // e.g. 'AEAD_AES_128_GCM' — null means no use_srtp!
    dtlsRole: transport.dtlsRole,         // 'client' | 'server'; must match your a=setup
    remoteFp: remote?.fingerprint?.toLowerCase() ?? null
  };

  // The real assertion: the cert that keyed media is the identity signaling authenticated.
  audit.pinned = audit.remoteFp === expectedRemoteFingerprint.toLowerCase();
  return audit;
}

Three checks make this useful rather than decorative. srtpCipher must be non-null — a null value with dtlsState: 'connected' is the use_srtp failure described above and means your media is not being protected because it is not flowing at all. dtlsRole must agree with the a=setup value you negotiated; a disagreement means some middlebox rewrote SDP. And remote.fingerprint must equal the value your authenticated signaling channel delivered, compared case-insensitively because Chrome and Firefox differ on hex casing.

It is worth measuring handshake duration explicitly rather than eyeballing it, because it is the one security-path number that degrades silently. Timestamp the transition of pc.iceConnectionState to connected and the transition of the transport’s dtlsState to connected, and record the delta as a histogram. On a direct regional path that delta should sit in the 30–80 ms band; through a TURN relay expect it to roughly double, since every flight pays the relay’s 20–40 ms one-way cost twice. A p95 above 1 s is the retransmission timer firing, which means flights are being lost, and a p99 above 3 s means multiple consecutive losses — both are network problems masquerading as crypto problems. Tracking this alongside your existing connection metrics turns “calls feel slow to start” into a specific claim about which stage is slow.

In chrome://webrtc-internals, the same values appear under the transport entry as dtlsState, dtlsCipher and srtpCipher, and the event log lines DtlsTransport state changed to connecting/connected give you timestamps you can subtract to measure real handshake duration. Firefox’s about:webrtc shows the DTLS state per transport in the ICE stats table. Reading these dumps well is a skill of its own, covered in Cross-Browser WebRTC Debugging. For the packet-level view, a Wireshark capture filtered on dtls shows the flights and, crucially, shows whether a retransmitted ClientHello is leaving your host and never being answered — the signature of a path that passes STUN but drops larger DTLS datagrams.

Edge Cases & Browser Quirks

Common Implementation Mistakes

FAQ

Do I need a real CA-signed certificate for WebRTC?

No, and one would buy you nothing. WebRTC ignores the certificate chain entirely; trust comes from the fingerprint hash delivered over authenticated signaling, not from a CA. Self-signed ECDSA P-256 with a short lifetime is the correct production configuration for both browsers and media servers.

Is media encrypted before the DTLS handshake finishes?

There is no media before the handshake finishes. Packets queued by the encoder are held until SRTP keys exist; the browser will not emit unprotected RTP. That is also why a slow handshake shows up as a black first second of video rather than a plaintext leak.

Why is srtpCipher null when dtlsState is connected?

The use_srtp extension was not negotiated, so no keys were exported. This usually means one endpoint offered a media profile of RTP/AVPF instead of UDP/TLS/RTP/SAVPF, or a gateway stripped the extension. The DTLS tunnel is fine and media will never flow.

Does rotating my server certificate break live calls?

No. Existing associations keep the key material they exported, so calls continue until they end naturally. Only new handshakes use the new certificate — which is exactly why you must publish the new fingerprint to clients that pin it before the file changes on disk.

Related: this section sits under the WebRTC Protocol Stack & Signaling Servers guide; go deeper with Verifying DTLS Fingerprints to Prevent MITM for the trust model, Debugging DTLS Handshake Failures when flights stall, and End-to-End Encryption with Insertable Streams when the media server must not see plaintext.