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.

How a rewriting signaling relay becomes an undetectable man in the middle Two stacked panels. The upper panel shows an honest relay passing each peer's fingerprint through unchanged, with a single direct DTLS association between the peers. The lower panel shows a hostile relay replacing both fingerprints with its own, producing two separate DTLS associations that each verify correctly while plaintext media exists at the relay. Honest relay β€” fingerprints pass through untouched Peer A publishes fp_A Signaling relay copies the SDP Peer B publishes fp_B fp_A fp_A one DTLS association β€” nobody holds the keys but A and B Hostile or compromised relay β€” both fingerprints replaced Peer A is told fp_M Relay holds key_M rewrites a=fingerprint Peer B is told fp_M fp_A in, fp_M out fp_B in, fp_M out DTLS leg 1 β€” verifies DTLS leg 2 β€” verifies both endpoints report a clean handshake; plaintext lives at the relay added latency ~1–3 ms β€” indistinguishable from normal forwarding
The substitution is invisible from both endpoints because each leg is genuinely, correctly encrypted.

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.

Deriving a fingerprint and comparing the three values that must agree A four-stage horizontal pipeline converts a PEM body to DER bytes, then to a SHA-256 digest, then to colon separated uppercase hex. Below it, three sources feed a comparator: the fingerprint claimed in the SDP, the fingerprint observed from getRemoteCertificates, and the pin delivered out of band. Agreement yields a trusted session; any divergence tears the session down. PEM base64 body headers stripped DER bytes hash exactly these SHA-256 crypto.subtle.digest 32 bytes as hex 95 chars, colon sep Derivation β€” one wrong input encoding gives a digest that never matches Hashing the PEM text, the base64 string, or the public key alone all produce plausible-looking wrong values. Claimed: SDP a=fingerprint whatever the relay delivered Observed: getRemoteCertificates bytes you hashed yourself Pinned: out-of-band value signed, or read aloud three-way comparison case-insensitive, all must agree all equal β€” session trusted divergence β€” close the pc
Only the observed value is derived from bytes you hashed yourself; the claimed value is an assertion by the relay.

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.

  1. 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.
  2. Add a rewrite hook: the relay generates its own certificate, replaces every a=fingerprint line in both directions, and terminates a RTCPeerConnection toward each tab. Media still flows; the WebSocket Signaling Implementation layer needs no other change.
  3. Watch both tabs report dtlsState: 'connected' with a valid dtlsCipher. Nothing in getStats() looks wrong in isolation β€” that is the point of the exercise.
  4. Log the derived word set in each tab. They now differ, which is the only signal you get.
  5. 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.

Reading a failed or unavailable fingerprint check A decision tree rooted at the fingerprint verification result. Three branches cover an empty certificate array, an observed value that matches the SDP but not the out-of-band pin, and a fingerprint advertised with the sha-1 digest algorithm. Each branch leads to its real cause and the required action, above a closing rule that no branch permits continuing. Verification result at dtls connected chain array is empty and stats cert is undefined matches SDP, not the pin both legs verified cleanly digest is sha-1 still legal SDP syntax Engine gap, not an attack Chromium ships an empty array use the stats digest, keep pinning Substitution or silent rotation the relay chose this certificate close, alert, re-pin out of band Downgrade attempt collisions are reachable offline reject before setRemoteDescription No branch resolves to "log a warning and continue" an identity you cannot verify is an identity you have not verified
Three distinct outcomes, three distinct causes β€” only the middle branch is an actual adversary.

Common Implementation Mistakes

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.