TURN Server Configuration & Auth

TURN relays carry the media that direct peer-to-peer paths cannot. When symmetric NAT, carrier-grade NAT, or a corporate firewall blocks every host and srflx candidate pair, the relay is the only transport that still connects the call β€” the candidate-pair mechanics of that case are dissected in Traversing Symmetric NAT with TURN β€” and a misconfigured relay fails silently, surfacing only as a failed ICE state minutes into a session. This guide is part of the WebRTC Protocol Stack & Signaling Servers guide, and it walks through standing up an authenticated, production-grade TURN relay end to end: listener and port configuration, time-bound HMAC credentials, secure delivery to the browser, and the verification commands that prove the relay actually allocates before you ship it.

The goal is a relay that authenticates every Allocate request against a rotating shared secret, advertises a routable public address, listens on both UDP and TLS, and bounds per-user bandwidth so a single abusive client cannot exhaust the node. Two focused references extend this guide: Configuring Coturn for Production TURN Relay for the full turnserver.conf and OS-level tuning, and Time-Limited TURN Credentials with HMAC for the exact credential-signing math.

TURN allocation and auth flow A browser fetches an ephemeral HMAC credential from the app backend, then sends an Allocate request to the TURN server on port 3478 or 5349, which validates the credential and reserves a relay port in the 49152 to 65535 range. Browser RTCPeerConnection ICE agent App backend signs credential HMAC-SHA1 TURN server coturn validates secret fetch Listener ports 3478 STUN/TURN UDP 5349 TURNS / TLS 443 TCP fallback relay 49152-65535 Allocate + username:credential -> 3478 / 5349 200 OK + relayed transport address media relayed Credential = expiry:userId + base64( HMAC-SHA1( username, static-auth-secret ) )
TURN allocation and authentication: the browser fetches a signed credential, sends an Allocate to ports 3478/5349, and the relay reserves a port in 49152–65535.

Step 1 β€” Provision listeners, ports, and public IP

A TURN relay needs three things reachable from the open internet: a control port for Allocate/Refresh messaging, a TLS port for clients behind deep-packet-inspection proxies, and a contiguous block of relay ports for the actual media. The control port is 3478 (shared with STUN), the TLS port is 5349 (turns://), and the relay range is conventionally 49152–65535 β€” the IANA ephemeral range. Many production deployments also bind TLS on 443 so the relay is indistinguishable from ordinary HTTPS to a filtering proxy, a listener whose certificate and ALPN details are worked through in Forcing TURN over TCP 443 on Locked-Down Networks.

The single most common deployment failure is a missing or inverted external-ip. On any cloud instance the OS sees only the private RFC 1918 address; coturn must be told the public address explicitly or it will advertise an unroutable relay candidate and every call behind symmetric NAT will fail. The format is PUBLIC_IP/PRIVATE_IP β€” public first.

# /etc/turnserver.conf β€” listeners and addressing
listening-ip=0.0.0.0          # bind all interfaces; coturn picks per-request
listening-port=3478           # STUN + plain TURN (UDP and TCP)
tls-listening-port=5349       # turns:// over TLS
external-ip=203.0.113.10/10.0.1.5   # PUBLIC/PRIVATE β€” public address first
realm=turn.example.com        # authentication realm advertised to clients
server-name=turn.example.com
min-port=49152                # start of the relay media port range
max-port=65535                # end of the relay range β€” open BOTH in the firewall
fingerprint                   # add FINGERPRINT to messages (strict clients require it)

Open the relay range in your security group as well as 3478/5349/443. Media flows through min-port–max-port, not through 3478 β€” a firewall that allows only 3478 lets allocation succeed yet drops every media packet, producing a connection that negotiates and then goes silent. The full annotated config, including no-tcp-relay trade-offs and kernel tuning, lives in Configuring Coturn for Production TURN Relay.

Port arithmetic and the capacity ceiling of one relay address

Each successful Allocate reserves exactly one relayed transport address β€” one UDP port taken from the min-port–max-port window β€” and holds it for the allocation lifetime, 600 seconds by default, extended by the Refresh requests browsers send well inside that window. That one-port-per-allocation rule sets a hard ceiling: 49152–65535 is 16,384 ports, so a single listening address tops out at 16,384 concurrent allocations no matter how much CPU or NIC headroom the box has. A two-party call in which both endpoints are relay-only consumes two allocations, and a conference in which every participant leg terminates on a media server consumes one allocation per leg, so the honest planning figure is nearer 8,000 relayed calls per address. Scale past that by adding addresses β€” coturn accepts repeated relay-ip lines, one per bound interface β€” rather than by widening the range. You cannot widen above 65535, and reaching below 49152 collides with the ephemeral sockets the kernel hands to the machine’s own outbound connections, which shows up as sporadic allocation failures under load that no amount of firewall auditing explains.

Bandwidth, not ports, is usually the binding constraint first. A relay is store-and-forward at the transport layer, so every relayed stream is counted twice on the node’s interface: ingress from the sender and egress to the receiver. One 500 kbps video stream relayed in both directions is 2 Mbps of relay traffic, while a 24–32 kbps Opus stream is rounding error. At a typical 10–20% relay rate across a consumer user base, a node carrying 1,000 concurrent video calls sees on the order of 200–400 Mbps sustained β€” which is why relay egress, not licence cost, dominates the economics of a fully relayed topology and why the SFU vs MCU Cost & Quality Trade-offs analysis matters even to teams who think of TURN as a corner case.

Permissions and channel bindings: why allocation alone is not connectivity

Reserving the port is only the first half of the handshake. Before any peer traffic crosses the relay, the client must install a permission for each remote address it intends to talk to via CreatePermission, and each permission expires 300 seconds after its last refresh. coturn discards inbound packets from any peer address without a live permission, and it does so without an error response β€” on the wire that is indistinguishable from packet loss. The signature is an asymmetric candidate pair: bytesSent climbing steadily while bytesReceived stays pinned at its last value, followed a few seconds later by the connection sliding into disconnected. If you have never had to separate that pattern from ordinary loss, the state-transition rules in Disconnected vs Failed ICE States are the fastest way to tell a lapsed permission from a genuinely dead path.

Once media is flowing, the client upgrades from Send/Data indications to a channel binding. The difference is framing overhead: a Send indication wraps every payload in a 36-byte STUN header, whereas ChannelData uses a 4-byte header. At the roughly 50 packets per second of a standard 20 ms Opus stream that is about 12.8 kbps saved per direction β€” a third of the audio stream itself, and the reason a relayed audio-only call costs far less than the naive STUN-framing estimate suggests. Chrome and Firefox both issue ChannelBind automatically once a permission exists; there is no JavaScript control surface for it. The practical consequence is for whoever reads the packet capture: relayed media on port 49152+ that does not begin with a STUN magic cookie is not corrupt, it is ChannelData, and a capture filter that keys on the STUN cookie will show you an empty relay carrying a healthy call.

Step 2 β€” Enable HMAC authentication and a rotating secret

Never ship static long-term usernames and passwords. A static credential embedded in a client bundle is scraped within hours and replayed to mine free relay bandwidth. The production model is the TURN REST API scheme (draft-uberti-behave-turn-rest): the server holds a single static-auth-secret, and your backend mints short-lived credentials by signing a timestamped username with HMAC-SHA1. The relay recomputes the same HMAC at allocation time, so no per-user state is stored and expired credentials are rejected automatically.

Enable the long-term credential mechanism and register the shared secret. stale-nonce forces nonce rotation and blocks replay of a captured handshake.

# /etc/turnserver.conf β€” authentication
lt-cred-mech                  # enable long-term credential mechanism (required for HMAC)
use-auth-secret               # use the REST-API shared-secret model, not per-user DB rows
static-auth-secret=BASE64_32_BYTE_SECRET   # the HMAC key; rotate via env injection
stale-nonce=600               # force a fresh nonce every 600s to defeat replay

The credential the browser presents is username = ${expiryUnixTimestamp}:${userId} and credential = base64(HMAC_SHA1(username, static-auth-secret)). Keep the TTL between 1 and 24 hours: shorter windows shrink the blast radius of a leaked token but force re-fetches on long calls and ICE restarts. coturn supports multiple static-auth-secret lines simultaneously, which is how you rotate with zero downtime β€” append the new secret, reload, and remove the old one after the longest outstanding TTL expires. The exact signing code, padding rules, and the failure log lines are covered in Time-Limited TURN Credentials with HMAC.

Ephemeral TURN credential field layout The username field splits into a unix expiry timestamp and an opaque user id joined by a colon; the credential field is the base64 of an HMAC-SHA1 over that username keyed by the static auth secret, which coturn recomputes to accept or reject the Allocate request. username = ${expiryUnixTimestamp}:${userId} 1780000000 : alice now + TTL, 1-24 h expired = 401 opaque user id no per-user DB row credential = base64( HMAC-SHA1( username, static-auth-secret ) ) hxK9Qm2pTdY7vN0sLb4RgW8cZ1E= signed on the backend only secret never reaches the bundle coturn recomputes the HMAC 1. expiry < now 401 2. digest mismatch 401 3. both match Allocate lt-cred-mech + use-auth-secret stale-nonce=600 blocks replay state held: one shared secret Zero-downtime rotation: append a second static-auth-secret, reload coturn, drop the old key once the longest TTL has expired.
Field layout of an ephemeral TURN credential and the three checks coturn runs before accepting an Allocate.

Bound the blast radius with quotas and peer filtering

Authentication answers who may allocate; it says nothing about how much they may allocate or where the relay will forward their packets. Both gaps are exploitable with a perfectly valid credential. Left unbounded, a relay will forward to any destination the operator’s own routing table can reach β€” including RFC 1918 space and the cloud metadata endpoint at 169.254.169.254. An attacker holding one legitimately minted, unexpired credential can therefore use your relay as a server-side request forgery pivot into the VPC that hosts it, reaching internal admin ports that are firewalled from the internet but wide open to the relay’s own NIC. Deny-list the private ranges explicitly; do not assume the defaults cover it.

# /etc/turnserver.conf β€” abuse containment
user-quota=6                 # concurrent allocations per credential: dual-stack + ICE-restart headroom
total-quota=1200             # server-wide allocation cap, deliberately under the 16384-port ceiling
max-bps=800000               # per-allocation ceiling: 500 kbps video + 32 kbps Opus + protocol overhead
no-multicast-peers           # never relay toward multicast destinations
denied-peer-ip=10.0.0.0-10.255.255.255         # keep the relay out of your own VPC
denied-peer-ip=172.16.0.0-172.31.255.255
denied-peer-ip=192.168.0.0-192.168.255.255
denied-peer-ip=169.254.0.0-169.254.255.255     # blocks the cloud metadata endpoint

Size user-quota against what one honest browser actually asks for, not against one call. A client that lists a UDP relay URL and a TLS relay URL gathers a relay candidate for each, and on a dual-stack host it may allocate over both IPv4 and IPv6 β€” four allocations from a single page load before anyone has restarted ICE. Set the quota to three or four times your per-URL count; anything tighter and a legitimate reconnect trips error 486 (Allocation Quota Reached) at exactly the moment the user is already suffering. max-bps is a per-allocation hard drop, not a shaper: coturn discards the excess rather than queuing it, so a cap set at the nominal encoder target converts every keyframe burst into visible loss. Leave 30–50% headroom above the top simulcast layer β€” 800 kbps for a 500 kbps top layer plus audio is a reasonable starting point, and you tighten it only after watching real outboundRtp peaks.

Step 3 β€” Deliver credentials and build the ICE server array

Credentials must reach the browser over an authenticated, encrypted channel before RTCPeerConnection is constructed β€” typically the same channel you already use for SDP, covered in WebSocket Signaling Implementation. Fetch them just-in-time from a backend endpoint that performs the HMAC signing server-side; the static secret must never touch the client.

Construct the iceServers array with both a turn: URL on 3478 and a turns: TLS URL, so the ICE agent can fall back to TLS-over-TCP when UDP is blocked. Pair the relay with a STUN Server Deployment Strategies endpoint so cheap srflx paths are tried before the relay is ever allocated.

// Fetch ephemeral credentials, then build the peer connection
const res   = await fetch('/api/turn-credentials', { credentials: 'include' });
const creds = await res.json();              // { username, credential, ttl }

const iceServers = [
  { urls: 'stun:stun.example.com:3478' },    // try srflx first β€” no relay bandwidth cost
  {
    urls: 'turn:turn.example.com:3478?transport=udp',  // primary relay over UDP
    username: creds.username,
    credential: creds.credential
  },
  {
    urls: 'turns:turn.example.com:5349?transport=tcp', // TLS fallback for DPI proxies
    username: creds.username,
    credential: creds.credential
  }
];

const pc = new RTCPeerConnection({
  iceServers,
  iceTransportPolicy: 'all'   // set 'relay' only to force relay-only paths for testing
});
TURN transport fallback decision tree Working down from UDP on port 3478 to TCP on 3478 and finally TLS on 443, each branch shows the iceServers URL to use and what to do when no transport is reachable at all. Order the iceServers array cheapest transport first UDP 3478 reachable? the default when ?transport= is omitted yes turn:turn.example.com:3478?transport=udp relay adds 20-40 ms one-way no TCP 3478 allowed? outbound policy permits raw TCP yes turn:turn.example.com:3478?transport=tcp must be explicit β€” no implicit TCP no TLS 443 open? DPI proxy passes HTTPS-shaped flows yes turns:turn.example.com:443?transport=tcp serve the full intermediate chain no no transport left ICE ends in failed allowlist turn.example.com on the proxy or deploy an on-premises relay Chrome bounds gathering at roughly 10-15 s in total, so every extra URL competes for the same window β€” list at most one relay per transport.
Transport fallback order behind the iceServers array: UDP 3478, then TCP 3478, then TLS on 443.

Refreshing credentials without rebuilding the peer connection

RTCPeerConnection copies the iceServers array at construction time. Mutating the array object you passed in has no effect whatsoever on the live connection β€” a mistake that survives review because nothing throws and nothing logs. The only supported path is setConfiguration(), and even that applies exclusively to the next gathering pass, so a refreshed credential does not touch the allocations already in flight; it is there so that the next restartIce() has something valid to authenticate with. Schedule the re-fetch at roughly 75% of the advertised TTL so a call that outlives its credential always has a fresh one staged before it is needed.

// Keep TURN credentials fresh across long sessions and ICE restarts
let refreshTimer;

async function applyFreshTurnCredentials(pc) {
  const res   = await fetch('/api/turn-credentials', { credentials: 'include' });
  const creds = await res.json();                 // { username, credential, ttl } β€” ttl in seconds

  const cfg = pc.getConfiguration();              // the snapshot taken at construction
  cfg.iceServers = cfg.iceServers.map(s =>
    String(s.urls).startsWith('turn')             // leave the stun: entry alone β€” it needs no auth
      ? { ...s, username: creds.username, credential: creds.credential }
      : s
  );
  pc.setConfiguration(cfg);                       // takes effect on the NEXT gathering pass only

  clearTimeout(refreshTimer);
  refreshTimer = setTimeout(() => applyFreshTurnCredentials(pc), creds.ttl * 750); // 75% of TTL, in ms
}

pc.addEventListener('icecandidateerror', (e) => {
  // 401 credential rejected, 486 per-user quota reached, 508 relay ports exhausted
  if (e.errorCode === 401) applyFreshTurnCredentials(pc);  // expired token or clock skew
  console.warn('ICE server error', e.url, e.errorCode, e.errorText);
});

The 401 branch exists mainly to catch clock skew, which is the most under-diagnosed cause of intermittent TURN failure. The expiry embedded in the username is compared against the relay’s clock, not the signer’s, so a backend running two minutes behind mints credentials that the relay considers already valid but that expire two minutes early β€” producing allocations that succeed for most users and fail for the ones whose call happened to start near the boundary. Keep both machines on NTP, sign from server time rather than any client-supplied timestamp, and add a 300-second safety margin to the TTL you advertise to the browser so the refresh timer always fires before the relay’s view of expiry. Chrome records every one of these rejections in the event log of a chrome://webrtc-internals dump, where a burst of iceCandidateError entries with errorText: "Unauthorized" against a single relay URL is unambiguous; the anatomy of that dump is broken down in Reading chrome://webrtc-internals Dumps.

Cache the credential object for the duration of ttl and reuse it across restarts β€” Triggering an ICE Restart Without Dropping Media walks through why the existing allocation survives β€” because re-fetching on every restartIce() adds avoidable signalling round-trips. To force the relay path during development and confirm it works in isolation, set iceTransportPolicy: 'relay' β€” this suppresses host and srflx candidates so only relay pairs remain.

Step 4 β€” Verification

Prove allocation works from the server before trusting it in production. coturn ships turnutils_uclient, which performs a real Allocate and relays test packets using a credential you supply.

# Mint a credential with your backend, then drive a real allocation through the relay
turnutils_uclient \
  -u "1780000000:alice" \
  -w "$(printf '%s' '1780000000:alice' \
        | openssl dgst -sha1 -hmac "$TURN_SECRET" -binary | base64)" \
  -y -m 10 turn.example.com    # -y verbose, -m 10 send 10 relayed messages

A healthy run logs allocate sent, allocate response received, and a relayed address in the 49152–65535 range. On the server, tail the log and confirm the matching allocation line.

# Watch coturn confirm or reject the allocation in real time
journalctl -u coturn -f \
  | grep -E "relayed address .* (allocated|not allocated)"
# success:  INFO: session ...: relayed address 203.0.113.10:51234 allocated
# failure:  ERROR: session ...: relayed address ... not allocated

Cross-check that the relayed IP equals the public half of your external-ip. From the browser side, poll pc.getStats() and confirm the selected candidate pair has localCandidateType === 'relay' with non-zero bytesSent/bytesReceived β€” that is the only definitive proof the relay is carrying media rather than just answering allocations.

Decoding allocation failures by STUN error code

When turnutils_uclient or the browser reports a rejection, the STUN error code names the fault precisely. Memorising the six that actually occur in production turns a multi-hour investigation into a one-line diagnosis.

Code Name What it really means Fix
401 Unauthorized Expected on the first Allocate β€” the client must retry with the nonce and realm from this response. Persistent 401 means expired credential, clock skew, or a digest mismatch. Verify NTP on both hosts; confirm use-auth-secret and the exact secret bytes match the signer.
403 Forbidden The peer address the client asked to reach is on a denied-peer-ip range. Intended for internal ranges. If it fires on real peers, your deny-list is too broad.
437 Allocation Mismatch The client’s 5-tuple already has an allocation, or the relay never saw the allocation the client thinks it owns. Usually a load balancer rewriting source addresses, or a client socket rebind.
438 Stale Nonce The nonce has aged past stale-nonce. Normal and self-healing β€” the client retries with the new nonce. Only a problem if the rate is high; raise stale-nonce toward 600 s.
486 Allocation Quota Reached This credential has hit user-quota. Raise the quota to cover multi-URL, dual-stack gathering, or mint per-session user ids.
508 Insufficient Capacity No relay port is free, or total-quota is hit. Add relay addresses; check for leaked allocations that never expired.

Two of these mislead reliably. Error 437 almost never means what its name suggests β€” in a WebRTC deployment it is nearly always infrastructure, because TURN binds an allocation to the client’s exact source address and port, and any layer-7 proxy or application load balancer in front of 5349 presents its own address for every client. The relay then sees thousands of clients collapsing into one 5-tuple and rejects all but the first. Terminate TURN TLS on the relay itself, or front it with a layer-4 load balancer that preserves the client address; never with an HTTP-aware proxy. Error 508 is the one that appears months after launch: allocations that are never explicitly released linger for the full 600-second lifetime, so a client crash loop can strand ports faster than they expire, and the port pool drains while CPU and bandwidth graphs look perfectly healthy. Alert on active allocations as a percentage of max-port βˆ’ min-port, not on CPU, and treat sustained occupancy above 60% as the signal to add an address.

Edge Cases & Browser Quirks

Common Implementation Mistakes

FAQ

When should I use static long-term credentials instead of ephemeral HMAC ones? Only for isolated internal test rigs where the relay is not internet-exposed. Any production relay must use the REST-API HMAC model (use-auth-secret + static-auth-secret) with a TTL between 1 and 24 hours, because static credentials are trivially scraped from client traffic and replayed.

How do I confirm a call is actually using the relay rather than a direct path? On the client, poll pc.getStats() for the succeeded candidate pair and check localCandidateType === 'relay' with rising bytesSent. On the server, grep coturn logs for relayed address … allocated and track active session counts.

Why does the relay work on home networks but fail on corporate ones? Corporate firewalls commonly block all UDP and restrict outbound to 80/443. Without a turns://…?transport=tcp listener on 443, those users have no reachable transport. Bind TLS on 443 and validate with turnutils_uclient over TLS before rollout.

Can I rotate the shared secret without dropping live calls? Yes. coturn accepts multiple static-auth-secret entries. Add the new secret, systemctl reload coturn, let outstanding credentials expire by their TTL, then remove the old secret β€” no active allocation is interrupted.

How many concurrent calls can one relay node carry? Three ceilings apply and the lowest one wins. Ports cap you at 16,384 allocations per listening address, which is roughly 8,000 two-party relayed calls. Bandwidth usually binds first: a relayed 500 kbps video stream costs 1 Mbps of node throughput in each direction, so 1,000 concurrent video calls is 200–400 Mbps sustained. CPU is rarely the limit for plain UDP relaying but becomes relevant once most traffic arrives over TLS on 443, where per-connection crypto is real work. Instrument all three and scale on whichever crosses 60% first.

Does the relay port range need to be open for TCP as well as UDP? The peer-facing side of a browser allocation is always UDP β€” browsers never request the TCP allocations that RFC 6062 defines, so traffic in 49152–65535 is UDP in practice. The client-facing side is what varies between UDP 3478, TCP 3478, and TLS 443, and those are separate listener ports, not part of the relay range. Opening TCP across the relay range as well is harmless and is what most security-group templates do, but the UDP rule is the one whose absence breaks calls.

Is it ever right to force iceTransportPolicy: 'relay' in production? Occasionally, and always as a deliberate trade. Forcing the relay adds 20–40 ms one-way, doubles your egress bill for those sessions, and removes the direct path that would otherwise carry most calls. In exchange you get a single predictable network path that is far easier to debug and support, and you stop exposing participants’ local and reflexive addresses to each other β€” which is why privacy-sensitive products and some regulated deployments accept the cost. For everything else, leave the policy at all and let ICE prefer the cheap path.

Related: this guide sits under WebRTC Protocol Stack & Signaling Servers; pair it with Configuring Coturn for Production TURN Relay, Time-Limited TURN Credentials with HMAC, STUN Server Deployment Strategies, and ICE Candidate Gathering & Filtering.