Verifying DTLS Fingerprints to Prevent MITM
The browser already checks that the certificate presented in the handshake hashes to the a=fingerprint value in the SDP it was handed β that check is automatic, unskippable, and worth very little on its own. This guide is part of the DTLS-SRTP Security & Encryption guide, and it answers the question that check leaves open: how do you establish that the fingerprint you received is the one your peer actually published, rather than one substituted by whatever sat between you?
Context & Trade-offs
Reason about it as two separate bindings. Binding one is cryptographic and free: certificate β fingerprint, enforced by the DTLS stack, unforgeable without a SHA-256 preimage. Binding two is fingerprint β human or account, and WebRTC supplies exactly nothing for it. Your signaling service is the thing asserting that binding, so the security of every call reduces to the integrity of a JSON blob travelling over a WebSocket you operate.
That is why the interesting attacker is not on the network path. TLS to the signaling endpoint already handles coffee-shop Wi-Fi, and an on-path attacker who cannot terminate that TLS session cannot touch the SDP at all. The attacker who matters is the one holding β or abusing β the relay itself: a compromised signaling node, a debug endpoint that echoes room state to any authenticated user, a room identifier guessable enough to join uninvited, or an operator with shell access and a reason to look. Each of these lets a party rewrite a=fingerprint in both directions, run two complete DTLS associations, and see plaintext media in the middle. Neither browser reports an anomaly, because from each endpointβs perspective everything verified correctly.
The economics favour the attacker uncomfortably. Substituting fingerprints costs no cryptographic work, only a string replacement in a message already being relayed in under 10 ms. The observable side effects are thin: an extra decrypt/re-encrypt hop adds a couple of milliseconds, comparable to normal SFU forwarding overhead of 1β3 ms, and if the honest path was already crossing a TURN relay at 20β40 ms one-way the added latency disappears entirely into the noise. There is no timing signal you can alert on.
So the defence has to come from a channel the relay does not control. Three options exist in practice and they trade off differently. Server-side attestation β the signaling service signs the fingerprint with a key clients pin β is invisible to users and defeats a network attacker or a rewriting proxy, but it does nothing against the service itself. The a=identity attribute and the identity-provider hooks in the spec were designed for exactly this and are effectively unimplemented across engines, so budget nothing for them. That leaves out-of-band comparison between the humans: both clients derive a short string from both fingerprints and the users confirm it matches over a channel the relay cannot reach.
Out-of-band verification is the only one that survives a fully compromised service, and its cost is user friction plus a bounded guessing risk. A raw SHA-256 fingerprint is 32 bytes rendered as 95 characters of colon-separated hex; nobody reads that aloud accurately. Compressing it to four words drawn from a 256-word list gives 32 bits, so an attacker who must commit to a substituted certificate before the users compare has a one-shot success probability of roughly 1 in 4.3 billion. Drop to two words and it is 1 in 65,536 β thin enough that a service willing to retry connection setup a few times will eventually win. Four words is the floor worth shipping.
| Approach | Stops on-path attacker | Stops your own service | Cost to ship |
|---|---|---|---|
| TLS on the signaling socket | yes | no | none, already required |
| Service signs the fingerprint | yes | no | key distribution + rotation |
a=identity / identity provider |
yes | partly | unimplemented across engines |
| Four-word string read aloud | yes | yes | ~10 s of user time per call |
Read the middle column as the deciding one. Everything except human comparison assumes the service is honest, which is exactly the assumption the exercise is meant to remove. That does not make the cheaper layers pointless β a signed fingerprint costs nothing at call time and eliminates every attacker who merely sits between the client and your edge β but it does mean that a product claiming resistance to its own operator has to put a string in front of a person. Most products do not need that claim, and should say so plainly rather than implying end-to-end guarantees the transport does not provide.
Minimal Runnable Implementation
RTCDtlsTransport.getRemoteCertificates() returns an array of ArrayBuffers holding the DER-encoded certificate chain the peer actually presented, leaf first. It is the only browser API that hands you the raw bytes rather than a digest someone else computed, which means you can hash them yourself and never trust an intermediaryβs arithmetic. Two caveats govern its use: it returns an empty array until the transport reaches connected, and several shipping Chromium builds return an empty array permanently. Fail over to the certificate report from getStats(), which carries the digest the browser computed internally.
// 32 raw bytes -> the 95-character uppercase colon hex that SDP's a=fingerprint uses.
const colonHex = (buf) => [...new Uint8Array(buf)]
.map(b => b.toString(16).padStart(2, '0').toUpperCase())
.join(':');
// Hash the DER bytes exactly as they arrived. Hashing PEM text or the base64 body
// yields a completely different digest β this is the single most common mistake here.
const sha256Der = async (der) => colonHex(await crypto.subtle.digest('SHA-256', der));
function dtlsTransportOf(pc) {
// Under max-bundle every sender and receiver shares one transport; take whichever exists.
const owner = pc.getSenders().find(s => s.transport)
|| pc.getReceivers().find(r => r.transport);
return owner ? owner.transport : null; // null before setLocalDescription() has run
}
async function observedRemoteFingerprint(pc) {
const transport = dtlsTransportOf(pc);
const chain = transport ? transport.getRemoteCertificates() : [];
if (chain.length) return sha256Der(chain[0]); // leaf cert; we hash it ourselves
// Fallback path for engines that ship an empty chain (Chromium) or thin certs (Safari).
const report = await pc.getStats();
const tp = [...report.values()].find(r => r.type === 'transport');
const cert = tp && report.get(tp.remoteCertificateId);
return cert ? cert.fingerprint.toUpperCase() : null; // engines differ on hex case
}
async function enforcePin(pc, pinnedFingerprint) {
const observed = await observedRemoteFingerprint(pc);
if (!observed) throw new Error('remote certificate not available yet');
if (observed !== pinnedFingerprint.toUpperCase()) {
pc.close(); // fail closed β never "warn and keep the media flowing"
throw new Error(`pin mismatch, observed ${observed}`);
}
return observed;
}
// Short authentication string: both peers must derive an identical 4 words.
async function shortAuthString(localFp, remoteFp, words /* 256 entries */) {
const ordered = [localFp, remoteFp].sort().join('|'); // sort so both ends agree on order
const bytes = new Uint8Array(
await crypto.subtle.digest('SHA-256', new TextEncoder().encode(ordered))
);
return [0, 1, 2, 3].map(i => words[bytes[i]]).join(' '); // 4 bytes = 32 bits of entropy
}
// Only meaningful once the handshake finished β before that the chain is empty by spec.
dtlsTransportOf(pc).onstatechange = async (e) => {
if (e.target.state !== 'connected') return;
const remote = await observedRemoteFingerprint(pc);
// getFingerprints() is synchronous and returns one entry per supported digest.
const local = pc.getConfiguration().certificates[0].getFingerprints()[0]
.value.toUpperCase();
showToUser(await shortAuthString(local, remote, WORDS)); // users read these aloud
};
Sorting the two fingerprints before hashing is what makes the string symmetric: without it, A and B derive different words and every session looks like an attack. The relay cannot forge a matching string, because under substitution Aβs pair is (fp_A, fp_M) and Bβs is (fp_M, fp_B) β two different inputs, two different word sets, and no way to reconcile them without breaking SHA-256.
Reproduction Steps & Debugging Log Patterns
You cannot trust a verification path you have never seen fail, so build the attack before you ship the defence.
- Stand up a local relay between two browser tabs that forwards every signaling message untouched, and confirm both tabs derive the same four words. This is your control run.
- Add a rewrite hook: the relay generates its own certificate, replaces every
a=fingerprintline in both directions, and terminates aRTCPeerConnectiontoward each tab. Media still flows; the WebSocket Signaling Implementation layer needs no other change. - Watch both tabs report
dtlsState: 'connected'with a validdtlsCipher. Nothing ingetStats()looks wrong in isolation β that is the point of the exercise. - Log the derived word set in each tab. They now differ, which is the only signal you get.
- Re-run with
enforcePin()wired to a fingerprint fetched over a second, independent channel, and confirm the connection closes before any frame is decoded.
// Control run β same relay, no rewriting:
// tab A observed E1:9F:0C:...:7D sas "amber ridgeline oxide sparrow"
// tab B observed 3C:44:B2:...:A0 sas "amber ridgeline oxide sparrow"
// Rewritten run β relay substitutes its own certificate on both legs:
// tab A observed 8B:20:F6:...:11 sas "cobalt fenland umber trellis"
// tab B observed 8B:20:F6:...:11 sas "harbour dune pewter lantern"
// ^ identical observed value on both sides is itself suspicious in a 1:1 call
// tab A dtlsState connected srtpCipher AEAD_AES_128_GCM <- looks perfectly healthy
// enforcePin: Error: pin mismatch, observed 8B:20:F6:...:11 <- the only real detection
That identical-observed-value line deserves attention. In a true peer-to-peer call each side should observe a different remote fingerprint; seeing the same value on both ends means one certificate is serving both legs. It is a cheap heuristic, though not a defence β it disappears the moment media legitimately traverses an SFU, since the server terminates DTLS by design and every participant then observes the same server certificate. If your topology is described by SFU vs MCU Topologies, fingerprint pinning verifies the serverβs identity and nothing about the other participants; protecting payloads from the server itself requires End-to-End Encryption with Insertable Streams.
Common Implementation Mistakes
- Verifying against the SDP you were just handed. Comparing
remoteCertificateIdβs fingerprint to thea=fingerprintin the same offer confirms only that the relay was internally consistent. The comparison must terminate at a value the relay never touched. - Hashing the wrong representation. SHA-256 over PEM text, over the base64 body, or over the SubjectPublicKeyInfo all produce well-formed 95-character strings that will never match. Hash the DER bytes;
getRemoteCertificates()already gives you them in that form. - Treating an empty certificate array as a verification failure. An empty array is the browser declining to expose the chain, not the peer failing a check. Fall through to the stats digest and only fail on an actual mismatch, or Chromium users get locked out of every call.
- Deriving the short string from one fingerprint. If the string depends only on the remote value, an attacker who substitutes symmetrically produces matching strings on both ends. Both fingerprints must enter the hash, sorted so the order is deterministic.
- Skipping re-verification after renegotiation. A second offer carrying
a=setup:actpassinvites a fresh DTLS association with a possibly different certificate; re-run the pin check on everystatechangeback toconnected, not once at call start. The rules governing when that happens are in the SDP Offer/Answer Lifecycle guide. - Retrying blindly on mismatch. Reconnect loops let an attacker who must guess a short string take repeated shots. Cap it the way ICE restarts are capped β at most 3 attempts β and surface the failure to the user instead of quietly re-dialling.
FAQ
If the browser verifies the fingerprint anyway, what am I adding?
The browser closes the gap between βthe SDP said Xβ and βthe certificate hashes to Xβ. You are closing the gap between βthe SDP said Xβ and βmy peer actually generated Xβ. Those are different claims, and only the second one survives a signaling service that rewrites messages.
Why does getRemoteCertificates() return an empty array in Chrome?
The method is specified but not populated in several Chromium builds; the transport is fine and the handshake completed normally. Read the digest from the certificate report reached through transport.remoteCertificateId instead β it is the same value, computed by the browser rather than by you. Confirm which path fired by cross-checking the transport entry described in Reading chrome://webrtc-internals Dumps.
Does a certificate rotation on my media server look like an attack to pinning clients?
Yes, and that is the correct behaviour. Publish the new fingerprint through the same channel that carries the pin, let clients accept either value during an overlap window of at least one rotation period, then retire the old one. Clients that only ever see one valid value will hard-fail the moment you swap the file on disk.
Related: return to DTLS-SRTP Security & Encryption for the handshake and keying mechanics, or read Debugging DTLS Handshake Failures when the association never reaches connected in the first place.