DEV Community

AiLaoHuYu
AiLaoHuYu

Posted on

TURN returns 401 forever: the one config line nobody reads

A TURN server that answers every request with 401 Unauthorized looks like a credentials problem. Most of the time it is a missing realm, and the reason it is hard to find is that the startup log tells you once and then never mentions it again.

The pattern is always the same. The relay was configured with use-auth-secret, the shared secret matches on both sides, the client sends a valid REST credential, and the log fills with this:

Default realm:
WARNING: ... you did not specify the default realm option
realm <> user <>: ... error 401: Unauthorized
Enter fullscreen mode Exit fullscreen mode

That empty <> in realm <> user <> is the whole bug.

The whole thing in one line: use-auth-secret still needs a realm, and coturn will not remind you twice.

Why an empty realm breaks authentication

use-auth-secret is the long-term credential mechanism with a shared secret. The client is expected to be challenged once and then retry with a proper credential — a first 401 is normal and healthy.

What is not normal is the stream. The challenge carries the realm, the client hashes username:realm:password for the REST scheme, and if the realm is empty the two sides are not answering the same question. The client retries, gets challenged with the same nothing, retries again, and eventually dies with a client-side 701 timeout. From the browser it looks like the relay simply refuses you.

Fix it with one line:

realm=turn.example.com
Enter fullscreen mode Exit fullscreen mode

A normal stable hostname is the right choice. If the server is only reachable by IP, use that IP consistently. A hostname pays off later when you add TLS certificates, and it keeps the realm readable in a packet capture.

The startup log is the actual debugging tool

This is the part worth internalising: read the ten lines before the first client request, not the hundred identical 401s after it.

A misspelled option, a wrong -c path, or a config file coturn cannot read all produce the same symptom — your edits do not exist. Start with an absolute path so there is no ambiguity about which file won:

turnserver -c /root/turnserver.conf
Enter fullscreen mode Exit fullscreen mode

Then confirm the startup output prints a non-empty Default realm. If it prints nothing, stop debugging credentials; nothing you do to the client will matter.

Realm is a deployment setting, not a knob

Once it is set, treat it as fixed. Changing the realm changes the authentication context clients see, so every cached or pre-generated credential has to be retested after the change. This is the kind of setting that silently invalidates a working integration during an otherwise harmless config edit.

Verify the shared secret with coturn's exact bytes

After the realm is in place, check the secret the same way coturn will. The password is base64(HMAC-SHA1(secret, username)) — not a direct HMAC of the password, and not an MD5 hash:

SECRET='mysecret'
EXP=$(($(date +%s) + 3600))
USER="$EXP:bob"
PASS=$(printf '%s' "$USER" | openssl dgst -binary -sha1 -hmac "$SECRET" | openssl base64)

printf 'username=%s\npassword=%s\n' "$USER" "$PASS"
Enter fullscreen mode Exit fullscreen mode

Two traps live in that command. A trailing newline or a set of quotes that got included in the secret value will produce a credential that is wrong by one or two bytes and looks perfect. And an expiry in the past produces a credential that is well-formed and rejected.

Paste the result into the Trickle ICE page against:

turn:12.34.567.89:3478?transport=udp
Enter fullscreen mode Exit fullscreen mode

The second config gap that looks identical

If the realm is correct and credentials verify but the client still cannot relay, check external-ip. When the machine has a private interface and the public address is attached by the provider's NAT, the relayed candidate must contain the public address:

external-ip=12.34.567.89/10.0.0.4
Enter fullscreen mode Exit fullscreen mode

Replace 10.0.0.4 with the actual local address coturn listens on. And open the UDP relay range in both places — the provider firewall and the host firewall. A control port that answers while the relay range is closed gives you a srflx candidate and no relay candidate, which the browser reports as a generic failure.

For a server-side check that removes the browser entirely:

turnutils_uclient -v \
  -u "$USER" \
  -w "$PASS" \
  -p 3478 \
  12.34.567.89
Enter fullscreen mode Exit fullscreen mode

If that allocates, the server is fine and the problem is in the client's credential or the network path.

The check order that saves time

  1. Read the startup log for Default realm. Empty means stop and fix the config.
  2. Start coturn with an absolute config path and confirm your file is the one loaded.
  3. Expect one 401, then an allocation. An endless 401 stream with a client 701 is the realm failure, not a wrong secret.
  4. Generate the REST credential with coturn's exact algorithm and check for stray whitespace or quotes.
  5. Verify external-ip whenever the host interface is private.
  6. Open the relay port range in both firewalls, not just the control port.
  7. Prove it with turnutils_uclient so you are not debugging TLS, browsers and JavaScript at the same time.

None of this is complicated. All of it is invisible until you know that the one line above the 401s is the line that matters.

I packaged a working self-hosted WebRTC stack — the Go signaling server and SFU, the browser publisher and viewer, the ICE restart and rejoin chain, the coturn configuration and a two-probe relay test that separates "port unreachable" from "allocation refused" — as a source kit you run on your own server:

https://lowlatencykit.com/

If you only want to know whether your own TURN server allocates, that test page is free and needs no account:

https://lowlatencykit.com/test/turn/

Top comments (0)