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.
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.
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.
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.
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
- Safari exposes thin certificate stats. WebKit populates
transport.dtlsStatereliably but has historically omitted or under-populatedlocalCertificateId/remoteCertificateId, soreport.get(...)returnsundefined. Fall back to parsingpc.remoteDescription.sdpfor the fingerprint on Safari rather than treating the missing report as a verification failure. - Chrome reports
tlsVersionas a hex string.'FEFD'is DTLS 1.2 and'FEFC'is DTLS 1.3, reflecting the on-the-wire version encoding. Firefox has reported a decimal-ish form in some versions. Normalise before comparing, and never string-sort these values to decide which is newer. certificatescannot be changed after construction. There is no rotation API on a liveRTCPeerConnection. Rotating a server certificate means new connections only; existing sessions keep the old key until they are torn down, so publish the new fingerprint before you swap the file.- A second offer with
setup:actpassrestarts DTLS. During renegotiation, re-offeringactpassinvites the answerer to pick a new role, which forces a fresh handshake and a brief media gap. Keep your existing role (activeorpassive) in subsequent offers unless you deliberately want a new DTLS association. An ICE restart alone does not restart DTLS: the association survives the path change, which is why Connection Recovery & ICE Restart can move media to a new pair without re-keying. - Firefox is stricter about non-bundled fingerprints. With
bundlePolicy: 'balanced'and mismatched per-m-section fingerprints, Firefox will fail the whole connection where Chrome may partially proceed. Usemax-bundleand one fingerprint. - TURN over TCP hides MTU problems. A large
Certificateflight that fragments and gets dropped over UDP frequently succeeds over a TCP relay, producing the maddening pattern “works on TURN, fails direct”. Configuring that fallback path is covered under TURN Server Configuration & Auth.
Common Implementation Mistakes
- Treating DTLS-SRTP as end-to-end encryption. It is hop-by-hop. Any SFU terminates DTLS, decrypts SRTP, and re-encrypts to each subscriber, so the server sees plaintext media. If your threat model excludes the server, you need End-to-End Encryption with Insertable Streams layered on top.
- Comparing fingerprints from the SDP you just received. Checking that the certificate matches the fingerprint in the same message the attacker rewrote proves nothing. The expected value must arrive over an independently authenticated channel.
- Accepting
sha-1fingerprints. SHA-1 fingerprints remain syntactically valid SDP and older gateways still emit them. Reject anything below SHA-256 in your signaling layer, since the browser will happily accept them. - Generating the certificate on the call button. RSA keygen in the click handler stalls the main thread for hundreds of milliseconds and shows up as slow call setup. Generate at app init, off the critical path.
- Both peers answering
actpass.actpassis only legal in an offer. Two peers that never resolve to client and server sit waiting for each other’sClientHellountil the connection times out, with no error message that mentions roles. The negotiation rules around this live in the SDP Offer/Answer Lifecycle guide. - Assuming a stalled handshake is a DTLS bug. If ICE never nominates a pair, DTLS never starts, and the symptom is an eternal
dtlsState: 'new'. Confirm the pair state first — see ICE Candidate Gathering & Filtering — before touching certificates.
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.