STUN answers a question. TURN does work. That difference is the whole story: a STUN server reads a source address off a packet and replies, while a TURN server allocates you a public address of its own and then forwards every byte of your call in both directions for as long as the call lasts. One is free to operate at scale; the other is a bandwidth bill.
This guide covers what TURN actually does on the wire — including the authentication exchange that confuses most first-time debuggers — the transport variants and when each one matters, the two credential schemes in real use, and why "port 3478 is open" is not evidence that your relay works.
What a relay allocation actually is
TURN, specified in RFC 8656 (which obsoleted RFC 5766), is built as an extension of STUN — same 20-byte header, same magic cookie, additional methods. The central one is Allocate.
When your browser sends an Allocate request, it is asking the server to reserve a public IP and port on the server and associate it with your session. The server replies with that address in an XOR-RELAYED-ADDRESS attribute. From that moment, anything sent to the relayed address gets forwarded to you, and anything you send through the server appears to come from it.
That relayed address is your relay candidate. It is a public address that works no matter how hostile your NAT is, because the NAT is no longer part of the equation — you established the path outbound, and everything rides it.
The 401 that is not an error
Here is the part that trips people up in packet captures. The first Allocate request is sent without credentials, and the server is required to reject it:
Client → Server Allocate request (no credentials)
Client ← Server Allocate error 401 (+ REALM, + NONCE)
Client → Server Allocate request (USERNAME, REALM, NONCE, MESSAGE-INTEGRITY)
Client ← Server Allocate success (XOR-RELAYED-ADDRESS, LIFETIME)
The 401 is not a failure. It is how the server hands the client the realm and a nonce so the client can compute a message integrity hash — the long-term credential mechanism inherited from STUN. Every successful TURN allocation you will ever capture begins with a 401.
This matters for debugging because a genuine credential failure looks almost identical at a glance: also a 401, but the exchange stops there instead of proceeding to a success. If you are watching TURN traffic and see the 401, do not conclude anything until you have looked at what follows it.
Permissions, channels, and the overhead you pay per packet
An allocation alone does not let arbitrary peers reach you — that would make the relay an open reflector. Two more mechanisms gate and optimise the traffic:
- CreatePermission tells the server which peer IP addresses are allowed to send to your relayed address. Permissions expire after 5 minutes and are refreshed while the call is live.
- ChannelBind associates a 16-bit channel number with a peer address. Once bound, data is framed with a 4-byte ChannelData header instead of the roughly 36 bytes a Send indication carries (20-byte STUN header, 12-byte XOR-PEER-ADDRESS, 4-byte DATA attribute header).
On a stream of small audio packets that overhead difference is not cosmetic. For a 60-byte Opus payload, Send indications add ~60% framing overhead; channels add under 7%. WebRTC implementations bind channels automatically, so you get this for free — but it explains why a naive TURN client written by hand can cost noticeably more bandwidth than the browser does.
Allocations themselves also expire — the default lifetime is 600 seconds, kept alive by Refresh requests. A relay that goes quiet mid-call and recovers on ICE restart is sometimes a refresh problem rather than a network one.
turn: versus turns:, and the transport query parameter
TURN URIs (RFC 7065) carry more configuration than their brevity suggests:
| URI | Client → server transport | Default port | Use when |
|---|---|---|---|
turn:host:3478 | UDP | 3478 | Default. Lowest latency, no head-of-line blocking. |
turn:host:3478?transport=tcp | TCP | 3478 | Outbound UDP blocked but arbitrary TCP allowed. |
turns:host:5349 | TLS over TCP | 5349 | Only TLS egress permitted; deep packet inspection in the path. |
Three things worth being precise about here:
- The transport parameter describes the client-to-server leg only. The server-to-peer leg of a WebRTC allocation is UDP regardless. Choosing
?transport=tcpdoes not make the far half of the path TCP. - TURNS is not about media confidentiality. Your media is already encrypted with SRTP before it reaches the relay, and the relay never holds those keys. TLS wraps the TURN control channel — it buys you traversal through networks that only pass TLS, and it hides the fact that you are doing TURN at all. It does not make relayed media "more encrypted."
- Running TURNS on 443 is the practical endgame. On networks that permit nothing but HTTPS, a TURN server listening for TLS on port 443 is frequently the only configuration that connects. The trade is real: TCP retransmission under loss is worse for real-time media than UDP's drop-and-continue, so this should be the last entry in your
iceServerslist, not the first.
A production configuration usually lists several, letting ICE prefer the cheapest that works:
const pc = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.example.com:3478' },
{
urls: [
'turn:turn.example.com:3478', // UDP — preferred
'turn:turn.example.com:3478?transport=tcp', // TCP fallback
'turns:turn.example.com:443', // TLS on 443 — last resort
],
username: 'ephemeral-username',
credential: 'ephemeral-password',
},
],
});
Credentials: static users versus time-limited secrets
TURN requires authentication because relaying costs the operator real money. There is no such thing as a public no-auth TURN server, and anything advertising itself as one is either about to disappear or is not what it claims.
Two schemes are in real use.
Static long-term credentials
A username and password configured on the server. Fine for a lab, wrong for production for an obvious reason: your web client has to ship them, which means every visitor can extract them from the network tab and use your relay for their own traffic. People do scan for this.
Time-limited REST credentials
The scheme every serious deployment uses. The server holds a shared secret; your backend derives a username that encodes an expiry timestamp and a password that is an HMAC of it. The TURN server validates the HMAC without any database lookup and rejects anything past its expiry.
// Server side. NEVER ship the secret to a browser.
import crypto from 'node:crypto';
function turnCredentials(userId, secret, ttlSeconds = 3600) {
const expiry = Math.floor(Date.now() / 1000) + ttlSeconds;
const username = `${expiry}:${userId}`;
const credential = crypto
.createHmac('sha1', secret)
.update(username)
.digest('base64');
return { username, credential, ttl: ttlSeconds };
}
On the coturn side this is two lines:
# /etc/turnserver.conf
use-auth-secret
static-auth-secret=THE_SAME_SECRET_YOUR_BACKEND_HOLDS
realm=example.com
A caveat that costs people an afternoon: use-auth-secret and static user= entries are alternative mechanisms. Enabling the secret scheme means your previously working static test user stops authenticating. That is expected behaviour, not a broken install.
Choose a TTL longer than your longest plausible call. Credentials are validated at allocation time, so a call that starts before expiry continues past it — but an ICE restart mid-call will re-allocate, and if the credentials have expired by then the call drops at exactly the moment the network hiccuped. An hour is a common choice; a few minutes is asking for trouble.
Why "the port is open" tells you almost nothing
This is the failure that motivated the tester on this site, so it deserves to be stated plainly.
A monitoring check that opens a TCP connection to port 3478 and sees it accepted will report your TURN server as healthy. That check is accurate about exactly one thing: a process is listening. It says nothing about whether the server can actually relay, because relaying happens on a completely different set of ports.
coturn allocates relayed addresses from a configurable range, by default UDP 49152–65535. If that range is not open in the firewall or security group, the sequence is:
- The client reaches 3478 and authenticates successfully.
- The server allocates a relayed address in the blocked range and returns it.
- No traffic ever traverses it.
Your monitoring is green, your credentials are correct, your logs show successful allocations, and every relayed call fails. It is one of the most common TURN misconfigurations precisely because every signal you normally trust says the server is fine.
The only check that catches it is one that gathers candidates and confirms a relay candidate comes back. That is a different question from "is the port listening," and it is the one your users experience.
Verifying a TURN server
From a browser, the success criterion is a single condition — a candidate whose type is relay:
const pc = new RTCPeerConnection({
iceServers: [{ urls: 'turn:turn.example.com:3478', username, credential }],
// Force relay so a working direct path cannot mask a broken TURN server.
iceTransportPolicy: 'relay',
});
pc.onicecandidate = ({ candidate }) => {
if (candidate) console.log(candidate.type, candidate.address, candidate.protocol);
};
pc.createDataChannel('probe');
pc.createOffer().then((o) => pc.setLocalDescription(o));
iceTransportPolicy: 'relay' is the important line for a test, because with the default policy a healthy direct connection will succeed and hide the fact that your relay is dead. It is equally important that this flag never ships to production: it forces every call through TURN and multiplies your relay bill by roughly five to ten. TURN cost optimization works through the arithmetic.
From a shell, coturn ships its own client:
turnutils_uclient -v -u USERNAME -w PASSWORD turn.example.com
Both checks answer the same question. The difference is where they run from — and since TURN failures are usually network-policy failures, running the check from the network in question is the point. The tester on this site does it in your browser for that reason, and never sends your credentials anywhere but the server you are testing.
Sizing and cost, briefly
TURN capacity planning is a bandwidth question, not a CPU one. The server does not decode or transcode anything — it copies packets — so a modest instance saturates its network interface long before its processor.
The arithmetic that matters: a relayed call pushes its full media volume through the server in both directions. A 1.5 Mbps video call is about 0.675 GB per direction per hour, so roughly 1.35 GB of relay traffic per call-hour. Multiply by your relayed call-hours, then by your provider's egress rate.
Only a minority of connections need the relay at all — commonly cited as 10–20% for a general consumer audience, though that is a rule of thumb rather than a measurement, and enterprise or mobile-heavy traffic runs considerably higher. Measure your own; the method is in the cost optimization guide, and the deployment walkthrough is in setting up coturn.
Common failure modes
| Symptom | Likely cause | First check |
|---|---|---|
| No relay candidate, STUN fine | Credentials rejected, or relay port range firewalled | Server logs — do you see a successful allocation? |
| Allocation succeeds, no media | Relay port range blocked downstream of the server | Open UDP 49152–65535, or narrow the range and open that |
| Worked yesterday, fails today | Expired time-limited credentials | Generate fresh ones and retry |
Works on turns:, not turn: | Network blocks UDP and non-TLS TCP | Nothing to fix — this is the fallback doing its job |
Certificate errors on turns: | Cert does not match the TURN hostname, or chain incomplete | openssl s_client -connect host:5349 |
| Relay works, call still one-way | Not a TURN problem — SDP direction or track handling | See common WebRTC errors |
Check Your Own Servers
Before you trust a TURN relay in production, confirm it allocates a relay candidate with your credentials. Our free tester checks UDP, TCP, and TLS transports so you know the fallback path really works.
Open the Free ICE Server Tester →