Traversing Symmetric NAT with TURN Relays
Symmetric NAT is the single most common reason a peer-to-peer WebRTC connection refuses to form despite perfect signalling. This guide is part of the ICE Candidate Gathering & Filtering guide, and it answers one decision: when the network defeats direct paths, how do you force a working relay path and how much success can you actually expect from it.
Context & Trade-offs
A STUN binding works because most NATs reuse the same external IP:port for a given internal socket regardless of destination β a so-called cone NAT. The srflx candidate that STUN discovers is therefore valid for any remote peer. Symmetric NAT breaks this assumption: it allocates a new external port for every distinct destination IP:port. The mapping your peer discovered by talking to the STUN Server Deployment Strategies endpoint is useless to the remote peer, because when packets later flow to that peer the NAT assigns a different port. The advertised srflx candidate points at a hole that does not exist for the actual conversation, and every connectivity check against it fails.
There is no client-side trick that beats this. Two peers both behind symmetric NAT cannot establish a direct path, because neither can predict the port the otherβs NAT will open. The only reliable answer is a relay: a TURN Server Configuration & Auth endpoint with a fixed, publicly reachable address that both peers send to. Each peer maintains a stable mapping to the TURN server, and the server forwards packets between them.
The numbers justify the cost. Across the open internet, roughly 8β20% of connections require a relay; for users behind symmetric NAT or carrier-grade NAT that figure climbs sharply, and pairs where both sides are symmetric approach 0% direct success. Provisioning TURN typically lifts overall connection success from the mid-80s into the high-90s percent range. The trade-off is latency and cost: relayed media adds roughly 20β40 ms of one-way latency depending on relay placement, and every byte traverses your infrastructure, so multi-region TURN placement (which cuts connect latency 40β60%) matters.
It helps to be precise about why the direct path is impossible rather than merely slow. With a cone NAT, the external mapping is a function of the internal socket alone, so the srflx candidate one peer learns from STUN is the same address every other peer will reach. With symmetric NAT the mapping is a function of the internal socket and the destination, so the external port that STUN revealed (when talking to your STUN server) is different from the external port the NAT will use when packets later flow toward the remote peer. The advertised candidate is therefore a prediction that is wrong by construction β there is no port-prediction heuristic that reliably beats it, and the ones that exist (birthday-attack style port scanning) are slow, fragile, and blocked by most carrier NATs. A relay sidesteps the problem entirely: both peers keep a single stable mapping to a fixed public address, and the TURN server stitches the two flows together. This is also why iceTransportPolicy: 'relay' is the cleanest way to prove a symmetric-NAT fix β if it connects with host and srflx suppressed, the relay is genuinely carrying the call.
Minimal Runnable Implementation
// Force a relay path so you can verify TURN works in isolation,
// then relax to 'all' in production to keep direct paths when available.
const pc = new RTCPeerConnection({
iceServers: [
{
urls: [
'turn:turn.example.com:3478?transport=udp', // primary relay
'turn:turn.example.com:3478?transport=tcp', // TCP fallback when UDP blocked
'turns:turn.example.com:5349?transport=tcp' // TLS fallback (firewalls, DPI)
],
username: 'time-limited-user', // from your HMAC credential service
credential: 'base64-hmac-token'
}
],
iceTransportPolicy: 'relay', // DROP host + srflx; relay candidates only
bundlePolicy: 'max-bundle',
rtcpMuxPolicy: 'require'
});
// Confirm a relay candidate was actually allocated
pc.onicecandidate = (e) => {
if (e.candidate && e.candidate.type === 'relay') {
console.log('Relay allocated:', e.candidate.address, e.candidate.port);
}
};
pc.onicecandidateerror = (e) => {
// 401 here means TURN auth failed β check username/credential expiry
console.error(`ICE error [${e.errorCode}] ${e.errorText} on ${e.url}`);
};
In production set iceTransportPolicy: 'all' so cone-NAT users still get a fast direct path; 'relay' is the diagnostic mode that proves your TURN deployment in isolation. Always offer UDP, TCP, and TLS (5349) transports so users behind UDP-blocking firewalls and deep-packet-inspection middleboxes still reach the relay.
Reproduction Steps & Debugging Log Patterns
- Put a test client behind a known symmetric NAT (many mobile carriers and enterprise firewalls qualify) and run with
iceTransportPolicy: 'all'. - Log every candidateβs
type; confirm srflx candidates are generated but never nominated. - Inspect
pc.getStats()for the nominatedcandidate-pairand read itslocal-candidate/remote-candidatetypes. - Switch to
iceTransportPolicy: 'relay'and confirm the connection still succeeds β proving the relay, not luck, carries the media.
Expected log on a symmetric-NAT pair that correctly falls back:
// candidate srflx 203.0.113.7:51000 <- generated...
// candidate-pair (srflx/srflx) state: failed <- ...but never works
// candidate relay 198.51.100.4:49500
// candidate-pair (relay/relay) state: succeeded nominated: true
// iceConnectionState: connected
If you see only failed pairs and no relay candidate at all, TURN allocation failed β almost always errorCode 401 (bad or expired credentials) or 701 (the relay is unreachable on 3478/5349).
Common Implementation Mistakes
- Shipping STUN-only
iceServers. Without a TURN entry, symmetric-NAT users have no working path and fail silently after gathering completes. - Relying on UDP transport alone. Networks that block UDP also block your TURN relay unless you offer
transport=tcpand a TLS endpoint on 5349. - Stale TURN credentials. Long-lived static credentials get revoked or expire; use short-lived HMAC tokens and refresh before they lapse, or
addIceCandidateof the relay fails with 401. - Forgetting
restartIce()on credential rotation. When a token expires mid-call, the relay drops; trigger an ICE restart (max 3 attempts) with fresh credentials rather than tearing down the call. - Testing only on cone NAT. A connection that works on your home router proves nothing about symmetric NAT β force
iceTransportPolicy: 'relay'in CI to exercise the relay path deterministically.
FAQ
Can two peers both behind symmetric NAT ever connect directly?
No. Neither can predict the external port the otherβs NAT will open for the specific destination, so direct connectivity checks always fail. A TURN relay is mandatory for that pair.
How do I know whether a user is behind symmetric NAT?
Issue STUN binding requests to two different server addresses; if the reflexive mapping (IP:port) differs between them, the NAT is symmetric. In practice it is simpler to always provision TURN and let ICE fall back automatically.
Does forcing iceTransportPolicy: 'relay' hurt normal users?
Yes β it adds 20β40 ms of latency and routes all media through your servers even when a direct path was available. Use it for diagnostics and strict-compliance environments only; default to 'all'.
Related: return to ICE Candidate Gathering & Filtering, and see WebRTC over CGNAT and ICE Candidate Trickle vs Bulk Gathering.