How the leak works
WebRTC is the browser API for peer-to-peer audio, video, and data connections — think browser video chat. For two peers to connect when both sit behind a home router (NAT — the address translation that lets many devices share one public IP), WebRTC needs to discover the addresses they can be reached at. It does this with the ICE protocol, which gathers candidate IPs from three places: your local network interface, your public IP via STUN servers (servers whose only job is to tell you what your public IP looks like from the outside), and TURN relays. Any web page can run this:
const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
pc.createDataChannel('');
pc.createOffer().then(o => pc.setLocalDescription(o));
pc.onicecandidate = (e) => { if (e.candidate) console.log(e.candidate.candidate); };The returned ICE candidates include your real IP, even if all your HTTP traffic is going through a proxy. The proxy hides your HTTP requests, but WebRTC went around it.
The 5-vector coherence test
Modern anti-bots check whether five different signals all tell the same geographic story. If you claim to be in one place, all five should agree:
- IP country — the country of your proxy's exit IP.
- Timezone — what the browser reports via
Intl.DateTimeFormat().resolvedOptions().timeZone. - Accept-Language header — your stated language preferences.
- WebRTC ICE candidate — the network the browser is actually connecting from (the leaked IP).
- DNS resolver location — which DNS server looked up the page's domain.
Picture a US proxy paired with an Accept-Language of ur-PK (Urdu, Pakistan), a timezone of Asia/Karachi, and a Pakistani WebRTC candidate — it fails immediately. It does not matter how good the proxy is; the contradiction between the vectors is itself the signal. This is why "use a US datacenter proxy and call it a day" stopped working around 2021.
Mitigation by tool
Camoufox with geoip=True looks up the country of your proxy exit IP, then sets timezone, locale, language, WebRTC ICE policy, and DNS to all match it. That one flag fixes the most common coherence failure in seconds. Playwright / Puppeteer need you to do this by hand — set locale, timezone_id, and Accept-Language yourself, and either disable WebRTC explicitly or route it through the proxy. HTTP scraping (curl_cffi, tls-client) has no WebRTC at all, so this vector never fires — part of why HTTP scraping beats browser scraping on many targets. Self-test: browserleaks.com/webrtc shows you exactly what WebRTC exposes from your setup. Run your browser context against it before you deploy.
The four candidate classes and what each one reveals
WebRTC discovers network paths by gathering ICE candidates, and the class of candidate matters as much as the address inside it. A probe reads the candidate list and checks its shape against what a real browser on a real network produces.
| Class | What it is | What its presence or absence says |
|---|---|---|
| host | A local interface address | Modern browsers publish these as an mDNS name ending in .local, not a raw LAN address. A literal private address is an older or non-standard stack; a routable public address here is a misconfigured host |
| srflx | The public address a STUN server observed | This is the classic leak: it reports the real network egress even when the page was fetched through a proxy |
| relay | An address on a TURN relay | Forcing relay-only gathering and comparing the result against the page origin is a second, independent read of egress |
| none | Gathering produced nothing | Suppressing WebRTC entirely is itself anomalous - real consumer browsers gather at least one candidate |
Two structural checks sit on top of the table. A server-reflexive candidate should have a host candidate backing it, since the reflexive address is discovered from a local interface; srflx with no host underneath indicates a filtered list rather than a real gathering run. And silence is not neutral - a browser that returns no candidates at all has removed a capability every ordinary browser has, which is a deviation in its own right.
SDP as an engine signature, and the egress coherence check
Beyond addresses, the session description a browser generates is itself an engine fingerprint. The set and ordering of RTP header extensions, the codec preference list, the exact formatting of the SDP offer, and which transport attributes appear are all implementation choices that differ between Chromium's and Firefox's WebRTC stacks. An offer that does not match the engine named in the User-Agent is a straightforward cross-API contradiction, and it is measured from a code path entirely separate from the JavaScript surfaces most modification targets.
The check that ties this together is egress coherence. The page arrived over some network path with an observable source address. WebRTC discovers its own path independently, through STUN or TURN, using a different socket and often a different protocol. Both should describe the same exit point. When the page origin resolves to one network and the reflexive candidate resolves to another, the browser is reaching the internet by two different routes - which is what a proxy applied at the HTTP layer but not the UDP layer looks like from the outside.
That same reasoning extends outward to locale. The exit address, the browser timezone, and the accepted languages are three independent claims about location, and a probe can compare all three - the mechanism described in timezone and IP mismatch. Consistency across them is a property of how the session was provisioned, not something a page script can arrange after the fact.
Handling this properly means routing every protocol through the same egress and provisioning locale to match, rather than disabling WebRTC and hoping the absence goes unnoticed. Infrastructure that owns both the browser and the network path can guarantee it; a browser flag cannot.
