---
key: "webrtc-security"
title: "Secure WebRTC signaling, TURN relays and media servers"
url: "https://softwaresecurity.com/domains/appsec/secure-features/webrtc-security/"
api: "https://softwaresecurity.com/api/v1/articles/webrtc-security.json"
domain: "appsec"
topic: "Application security"
type: "Guide"
identifiers: ["CWE-441","CWE-770","CWE-295","CWE-359","V17","V17.1.1","V17.1.2","V17.2.1","V17.2.2","V17.2.3","V17.2.4","V17.2.5","V17.2.6","V17.2.7","V17.2.8","V17.3.1","V17.3.2"]
tags: ["Denial of service and resource limits","TLS","Privacy engineering","API security"]
stages: ["design","build"]
roles: ["developer","platform-sre"]
license: "CC-BY-4.0"
license_url: "https://creativecommons.org/licenses/by/4.0/"
code_license: "MIT"
---

# Secure WebRTC signaling, TURN relays and media servers

WebRTC encrypts media, but signaling decides whose keys you trust and TURN relays wherever it is allowed. Authorize every signaling message, mint short-lived TURN credentials, deny internal addresses and harden media servers.

Canonical URL: https://softwaresecurity.com/domains/appsec/secure-features/webrtc-security/

## In short

Treat the signaling channel as the root of trust: authenticate the WebSocket at upgrade with a short-lived ticket and an Origin allowlist, authorize every room join on the server, assign peer IDs yourself and relay offers, answers and candidates only between members of the same room, with size and rate limits. Never ship static TURN credentials; mint per-session ones with the TURN REST API scheme (`use-auth-secret`), and set `denied-peer-ip` for private, loopback, link-local, metadata and other reserved ranges in IPv4 and IPv6, plus per-user and total quotas. Keep media on DTLS-SRTP with fingerprints checked against the SDP, patch media servers, drop packets from addresses ICE has not validated, keep admin APIs off public interfaces, and offer relay-only ICE when users must not learn each other's IP address.

- RFC 8827 says that even with HTTPS the signaling server can mount a man-in-the-middle attack unless keys are verified independently, because the DTLS fingerprints travel through it.
- Authorization belongs on the server: room IDs are not secrets, client-supplied sender IDs are lies, and a signaling server that relays to any peer ID lets anyone reach anyone.
- A TURN server is an outbound proxy: in the test, coturn 4.18.0 refused loopback and link-local peers by default but relayed to `10.0.0.1` until `denied-peer-ip` ranges were added (ASVS 5.0 V17.1.1).
- TURN REST API credentials expire by construction: the username carries the expiry time and the password is an HMAC of it, so leaked credentials stop working.
- Host and server-reflexive candidates reveal IP addresses to the other peer; `iceTransportPolicy: 'relay'` hides them at the cost of always relaying.
- Media servers must survive malformed and flooding SRTP and DTLS traffic; FreeSWITCH's fix for a DTLS ClientHello race (CVE-2023-51443) was to drop packets from addresses not validated by ICE.

## Recommended defaults

| Setting | Value | Note | Source |
| --- | --- | --- | --- |
| Signaling authentication | One-time ticket (30 seconds) or session cookie checked at the WebSocket upgrade, plus an Origin allowlist | Any site can open a WebSocket to your server, and cookies not restricted by SameSite go with it, so check the Origin. | RFC 6455 10.2; ASVS 5.0 V17.3 |
| Signaling authorization | Server-side check on every join; relay only within a room; server-assigned peer IDs | Ignore any `from` field the client sends. | RFC 8827 section 8 |
| Signaling limits | 16 KiB per message, about 20 messages a second with a burst of 40 per connection | Tune to your SDP sizes and candidate counts; close with 1008 or 1009. | ASVS 5.0 V17.3.1, V17.3.2 |
| TURN credentials | `use-auth-secret` with per-session credentials valid for minutes to a few hours | Rotate the shared secret; never put static credentials in client code. | coturn README.turnserver: TURN REST API |
| TURN peer addresses | `denied-peer-ip` for 0.0.0.0/8, 10/8, 100.64/10, 127/8, 169.254/16, 172.16/12, 192.0.0/24, 192.168/16, 198.18/15, 240/4, ::1, 64:ff9b::/96, fc00::/7, fe80::/10; `no-multicast-peers` | coturn 4.18.0 refuses loopback and link-local peers by default, but not RFC 1918 ranges; never set `allow-loopback-peers` in production. | coturn 4.18.0 turnserver.conf and test; ASVS 5.0 V17.1.1 |
| TURN quotas | `user-quota`, `total-quota`, `max-bps` and `bps-capacity` set; default `max-allocate-lifetime=3600` | coturn's quotas default to 0, meaning unlimited. | coturn 4.18.0 turnserver.conf; ASVS 5.0 V17.1.2 |
| ICE transport policy | `all` by default; `relay` for calls where users must not learn each other's IP address | Relay-only costs bandwidth and latency. | W3C WebRTC: RTCIceTransportPolicy |
| DTLS and SRTP | DTLS 1.2 or later with `TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256`; `SRTP_AEAD_AES_128_GCM` preferred, `SRTP_AES128_CM_HMAC_SHA1_80` supported | No NULL ciphers, no SDES, no renegotiation; check the certificate against `a=fingerprint`. | RFC 8827 section 6.5; RFC 7714 |
| Media server admin interfaces | Disabled, or bound to a private interface with a strong secret | Janus ships its admin HTTP endpoint disabled and a sample `admin_secret` that must be changed if you enable it. | Janus Gateway sample configuration |

## Verify it

1. Open the signaling WebSocket without a ticket or cookie, with a reused ticket and with a foreign `Origin`; each upgrade must be refused.
2. As a user without access, send `join` for another room, and send an offer or candidate to a peer ID from another room; both must be refused, and relayed messages must carry the server-assigned sender.
3. Send an oversized frame and a burst of messages; the server must close the connection and keep serving other users.
4. Inspect the client code and configuration for static TURN credentials; minted ones must expire within your lifetime limit.
5. From a test client, request TURN permissions to `127.0.0.1`, `10.0.0.1`, `169.254.169.254`, `::1` and `fd00:ec2::254`; all must be refused.
6. In `chrome://webrtc-internals` or `about:webrtc`, confirm the negotiated DTLS suite and SRTP profile, and that relay-only mode shows only relay candidates.
7. Confirm media server and TURN admin interfaces are unreachable from the internet and have non-default secrets.

## What you will learn

- Explain why signaling integrity decides media confidentiality in WebRTC.
- Authenticate signaling at the WebSocket upgrade and authorize every room join and relayed message.
- Mint short-lived TURN credentials and configure coturn so it cannot relay to internal addresses.
- Decide when to restrict ICE candidates to relays to hide users' IP addresses.
- Verify DTLS-SRTP settings and media server robustness against all 12 ASVS 5.0 V17 requirements.

Audience: Developers and platform engineers who build or run video calling, voice, screen sharing or data-channel features with WebRTC, and reviewers who assess them against ASVS 5.0 V17.

Before you start:

- Know the roles of signaling, STUN, TURN and ICE in setting up a WebRTC call.
- Know how your application authenticates users and WebSocket connections.
- For the examples: Node.js 22.23.2 or later, Python 3.13 or later, and Docker for the coturn test.

Time: About 60 minutes: 25 to read, 15 to run the signaling and coturn tests and 20 for the exercise

## Encryption is mandatory; trust in signaling is not

WebRTC was designed with encryption on: RFC 8827 requires every media channel to use SRTP keyed with DTLS-SRTP, forbids NULL ciphers and SDP security descriptions, and requires DTLS for data channels. What it cannot do is tell a peer who is on the other end. The certificate fingerprints that bind the DTLS handshake to the call travel in the SDP, through your signaling server. RFC 8827 says it directly: even if HTTPS is used, the signaling server can potentially mount a man-in-the-middle attack unless implementations have some mechanism for independently verifying keys.

The identity-provider mechanism RFC 8827 defines for that independent verification is implemented only in Firefox, according to MDN's compatibility data, so most applications rely on their signaling service being honest and uncompromised. That makes the signaling server's authentication, authorization and integrity the security boundary for the media as well. Applications that cannot trust their own media servers, such as end-to-end encrypted conferencing, add a layer such as SFrame (RFC 9605) through encoded transforms, with keys the server never sees.

OWASP ASVS 5.0 gives WebRTC its own chapter, V17, with 12 requirements in three sections: TURN Server (V17.1), Media (V17.2) and Signaling (V17.3). They focus on infrastructure: relay restrictions, DTLS-SRTP configuration and resilience to floods and malformed packets. This guide adds the application-level authorization that ASVS covers in its general chapters, then maps every V17 requirement in the last table.

## Authenticate and authorize every signaling message

Authenticate the WebSocket before upgrading it. Browsers cannot set an `Authorization` header on a WebSocket, so use the session cookie or a short-lived, single-use ticket fetched over authenticated HTTPS. Either way check the `Origin` header against an allowlist: any web page can open a WebSocket to your server, browsers send cookies that SameSite does not restrict with that upgrade, and RFC 6455 leaves the Origin check to the server, so without it another site can open an authenticated signaling connection in the user's name. The WebSocket controls in /domains/runtime/api-security/protocol-api-security/ apply unchanged.

Then authorize on every message, not once. A user may join only rooms the application says they may join; a room ID in a URL is a name, not a credential. Assign each connection a peer ID on the server and stamp it on every relayed message, ignoring any `from` the client sends, so nobody can impersonate another participant. Deliver offers, answers and ICE candidates only to peers in the same room. Validate message types and field sizes, bound the frame size, and rate-limit each connection, as ASVS 5.0 V17.3.1 and V17.3.2 ask for floods and malformed messages.

The server below does all of that with the `ws` package. Its tests cover a missing ticket, a reused ticket, a foreign Origin, a join without permission, a candidate sent to a peer in another room, a spoofed sender, a 17 KiB frame (closed with 1009) and a flood of 200 messages (closed with 1008). Run them with `node --test`; all six pass on Node.js 26.11.1 with ws 8.22.0.

```js
import {createServer} from 'node:http';
import {randomBytes, randomUUID} from 'node:crypto';
import {WebSocketServer} from 'ws';

const ALLOWED_ORIGINS = new Set(['https://meet.example.test']);
const MAX_MESSAGE_BYTES = 16 * 1024; // SDP offers are a few KB
const RATE = {perSecond: 20, burst: 40};
const TYPES = new Set(['join', 'offer', 'answer', 'candidate', 'leave']);

/**
 * A signaling server that authenticates at upgrade, authorizes every room join and every relayed
 * message, bounds message size and rate, and sets the sender itself.
 * canJoin(userId, roomId) is the application's authorization decision.
 */
export function createSignalingServer({canJoin}) {
  const tickets = new Map(); // one-time ticket -> {userId, expires}
  const rooms = new Map(); // roomId -> Map(peerId -> socket)

  // Called by an authenticated HTTPS endpoint (not shown); the page passes the ticket in the URL.
  function issueTicket(userId) {
    const ticket = randomBytes(32).toString('base64url');
    tickets.set(ticket, {userId, expires: Date.now() + 30_000});
    return ticket;
  }

  const server = createServer((req, res) => res.writeHead(404).end());
  const wss = new WebSocketServer({noServer: true, maxPayload: MAX_MESSAGE_BYTES});

  server.on('upgrade', (req, socket, head) => {
    const reject = status => socket.end(`HTTP/1.1 ${status}\r\nConnection: close\r\n\r\n`);
    if (!ALLOWED_ORIGINS.has(req.headers.origin)) return reject('403 Forbidden');
    const ticket = new URL(req.url, 'http://localhost').searchParams.get('ticket');
    const grant = ticket && tickets.get(ticket);
    tickets.delete(ticket); // single use, whether or not it was valid
    if (!grant || grant.expires < Date.now()) return reject('401 Unauthorized');
    wss.handleUpgrade(req, socket, head, ws => wss.emit('connection', ws, grant.userId));
  });

  wss.on('connection', (ws, userId) => {
    const peerId = randomUUID(); // server-assigned; clients never choose identities
    let room = null;
    let tokens = RATE.burst;
    let last = Date.now();
    const send = (target, msg) => target.send(JSON.stringify(msg));
    ws.on('error', () => ws.terminate()); // e.g. a frame over maxPayload; ws has already sent 1009

    ws.on('message', (data, isBinary) => {
      const now = Date.now();
      tokens = Math.min(RATE.burst, tokens + ((now - last) / 1000) * RATE.perSecond);
      last = now;
      if (tokens < 1) return ws.close(1008, 'rate limit');
      tokens -= 1;

      let msg;
      try {
        msg = isBinary ? null : JSON.parse(data.toString('utf8'));
      } catch {
        msg = null;
      }
      if (!msg || typeof msg !== 'object' || !TYPES.has(msg.type)) return send(ws, {type: 'error', error: 'bad message'});

      if (msg.type === 'join') {
        if (typeof msg.room !== 'string' || !canJoin(userId, msg.room)) return send(ws, {type: 'error', error: 'forbidden'});
        room = msg.room;
        if (!rooms.has(room)) rooms.set(room, new Map());
        const members = rooms.get(room);
        for (const other of members.values()) send(other, {type: 'peer-joined', peer: peerId});
        members.set(peerId, ws);
        return send(ws, {type: 'joined', peer: peerId, peers: [...members.keys()].filter(p => p !== peerId)});
      }
      if (msg.type === 'leave') return ws.close(1000);

      // offer, answer, candidate: only to a peer in the same room, with bounded fields.
      const target = room && rooms.get(room)?.get(msg.to);
      if (!target) return send(ws, {type: 'error', error: 'unknown peer'});
      const payload = msg.type === 'candidate' ? msg.candidate : msg.sdp;
      if (typeof payload !== 'string' || payload.length > (msg.type === 'candidate' ? 1024 : 12 * 1024)) {
        return send(ws, {type: 'error', error: 'bad payload'});
      }
      send(target, {type: msg.type, from: peerId, [msg.type === 'candidate' ? 'candidate' : 'sdp']: payload});
    });

    ws.on('close', () => {
      const members = room && rooms.get(room);
      if (!members) return;
      members.delete(peerId);
      for (const other of members.values()) send(other, {type: 'peer-left', peer: peerId});
      if (members.size === 0) rooms.delete(room);
    });
  });

  return {server, issueTicket};
}
```

## Mint short-lived TURN credentials

Static TURN credentials in client code are public: anyone can copy them and use your relay as free, anonymous bandwidth or as a proxy into your network. The common alternative is the TURN REST API scheme from an Internet-Draft by Justin Uberti, which expired in 2014 but is what coturn implements with `use-auth-secret`. Your web server and the TURN server share a secret; for each session the web server issues the username `<expiry unix time>:<user id>` and the password `base64(HMAC-SHA1(secret, username))`. The TURN server recomputes the HMAC and refuses the request once the time in the username has passed.

Keep the lifetime short, minutes to a few hours, and tie issuance to an authenticated session so you can stop issuing for an abusive account. Validate the user ID so it cannot contain the `:` separator. Rotate the shared secret; coturn can also read it from its database so it can change without a restart. The Node.js function and this Python one produce the same credential for the same inputs, and `openssl dgst -sha1 -hmac` gives the same value; coturn 4.18.0 accepted credentials minted this way and refused expired and wrong ones in the test in the next section.

```python
import base64
import hashlib
import hmac
import re
import time


def turn_credentials(secret: bytes, user_id: str, ttl_seconds: int = 3600, now: float | None = None) -> dict:
    """TURN REST API credentials for coturn's use-auth-secret: "<expiry>:<user>" and its HMAC-SHA1."""
    if not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", user_id):
        raise ValueError("invalid user id")
    username = f"{int(now if now is not None else time.time()) + ttl_seconds}:{user_id}"
    credential = base64.b64encode(hmac.new(secret, username.encode(), hashlib.sha1).digest()).decode()
    return {"username": username, "credential": credential, "ttl": ttl_seconds}

# Node.js equivalent (turn-credentials.js):
# import {createHmac} from 'node:crypto';
#
# // TURN REST API credentials, as coturn's use-auth-secret checks them: the username is
# // "<expiry unix time>:<user id>" and the password is base64(HMAC-SHA1(secret, username)).
# export function turnCredentials(secret, userId, {ttlSeconds = 3600, now = Date.now()} = {}) {
#   if (!/^[A-Za-z0-9_-]{1,64}$/.test(userId)) throw new Error('invalid user id');
#   const username = `${Math.floor(now / 1000) + ttlSeconds}:${userId}`;
#   const credential = createHmac('sha1', secret).update(username).digest('base64');
#   return {username, credential, ttl: ttlSeconds};
# }
```

## Stop the TURN server relaying inward

A TURN server opens connections to whatever peer address an authenticated client asks for, so it is a server-side request forgery primitive by design: from your network, it can reach internal services, the cloud metadata endpoint and loopback-only admin ports. ASVS 5.0 V17.1.1 requires that it only allow access to addresses not reserved for special purposes, in IPv4 and IPv6. History shows why both families and odd forms matter: coturn before 4.5.2 refused `127.x.x.x` but could be made to relay to loopback through `0.0.0.0`, `[::1]` or `[::]` (CVE-2020-26262, CWE-441).

coturn 4.18.0 refuses loopback peers unless `allow-loopback-peers` is set, and in our test it also refused the link-local metadata address `169.254.169.254` with no configuration, but it relayed to the RFC 1918 address `10.0.0.1` until `denied-peer-ip` lines were added. The configuration below denies the private, loopback, link-local, carrier-grade NAT, benchmarking and reserved ranges, IPv6 unique local and link-local addresses and NAT64, blocks multicast peers, leaves the admin CLI off and sets quotas for V17.1.2. Add your own address space if it is public but internal, and configure `cert` and `pkey` so `turns:` works over TLS on port 5349.

```ini
# Hardened coturn for a WebRTC app (TURN REST API credentials).
listening-ip=127.0.0.1
relay-ip=127.0.0.1
listening-port=3478
realm=turn.example.test
use-auth-secret
static-auth-secret=test-only-shared-secret
fingerprint
# The admin CLI is off by default in coturn 4.18 (enable it only with --cli); keep it off.
no-multicast-peers
stale-nonce=600
user-quota=10
total-quota=1000
max-bps=1000000
max-allocate-lifetime=3600
# Never relay to private, loopback, link-local, metadata, CGNAT or reserved ranges.
denied-peer-ip=0.0.0.0-0.255.255.255
denied-peer-ip=10.0.0.0-10.255.255.255
denied-peer-ip=100.64.0.0-100.127.255.255
denied-peer-ip=127.0.0.0-127.255.255.255
denied-peer-ip=169.254.0.0-169.254.255.255
denied-peer-ip=172.16.0.0-172.31.255.255
denied-peer-ip=192.0.0.0-192.0.0.255
denied-peer-ip=192.168.0.0-192.168.255.255
denied-peer-ip=198.18.0.0-198.19.255.255
denied-peer-ip=240.0.0.0-255.255.255.255
denied-peer-ip=::1
denied-peer-ip=64:ff9b::-64:ff9b::ffff:ffff
denied-peer-ip=fc00::-fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff
denied-peer-ip=fe80::-febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff
# TLS for turns: URLs on 5349 (the test ran without a certificate, so only plain TURN started).
# cert=/etc/coturn/tls/fullchain.pem
# pkey=/etc/coturn/tls/privkey.pem
log-file=stdout
simple-log
```

## Test the relay from outside

Repeat this test against your own TURN server after every configuration change: allocate with valid credentials, then ask for permissions to loopback, private, link-local and metadata addresses and confirm each is refused. coturn's `turnutils_uclient` does it with `-e <peer>`, and the server log names the range that matched. The script ran in `coturn/coturn:4.18.0` with `--network=none` in both modes: with the guide's configuration all six checks passed, including refused expired and wrong credentials; with the `denied-peer-ip` lines removed, `10.0.0.1` was no longer refused. Run it only against servers you operate.

```bash
#!/bin/sh
# Runs inside coturn/coturn:4.18.0 with --network=none. Starts turnserver on loopback and probes it.
#   probe.sh hardened   the guide's turnserver.conf
#   probe.sh defaults   the same file without its denied-peer-ip lines
# Exit status 0 only if every expectation holds.
CONF=/work/turnserver.conf
if [ "$1" = defaults ]; then grep -v '^denied-peer-ip' "$CONF" > /tmp/defaults.conf; CONF=/tmp/defaults.conf; fi
turnserver -c "$CONF" > /tmp/turn.log 2>&1 &
sleep 1
SECRET=test-only-shared-secret
fail=0
mint() { # $1 = expiry in unix seconds; prints "username password" (TURN REST API scheme)
  user="$1:alice"
  pass=$(printf '%s' "$user" | openssl dgst -sha1 -hmac "$SECRET" -binary | openssl base64)
  echo "$user $pass"
}
check() { # $1 = description, $2 = expected (yes|no), $3 = pattern, $4 = file
  if grep -q -E "$3" "$4"; then found=yes; else found=no; fi
  if [ "$found" = "$2" ]; then echo "ok   $1"; else echo "FAIL $1"; fail=1; fi
}
probe() { turnutils_uclient -v -n 1 -m 1 -u "$1" -w "$2" -e "$3" 127.0.0.1 > "/tmp/$4.log" 2>&1; }
set -- $(mint $(( $(date +%s) + 3600 )))
U=$1 P=$2
probe "$U" "$P" 127.0.0.1 loopback
probe "$U" "$P" 10.0.0.1 private
probe "$U" "$P" 169.254.169.254 metadata
check "valid credentials: allocation succeeds" yes "Received relay addr" /tmp/loopback.log
check "loopback peer refused" yes "error 403" /tmp/loopback.log
check "link-local metadata address refused" yes "error 403" /tmp/metadata.log
if [ "$CONF" = /work/turnserver.conf ]; then
  check "private 10.0.0.1 refused (denied-peer-ip)" yes "error 403" /tmp/private.log
  set -- $(mint $(( $(date +%s) - 60 )))
  probe "$1" "$2" 192.0.2.1 expired
  check "expired credentials: allocation refused" yes "Cannot complete Allocation" /tmp/expired.log
  probe "$U" "guessed" 192.0.2.1 wrong
  check "wrong password: allocation refused" yes "Cannot complete Allocation" /tmp/wrong.log
else
  check "defaults alone do NOT refuse private 10.0.0.1" no "error 403" /tmp/private.log
fi
exit $fail
```

## Decide what ICE candidates reveal

ICE candidates are addresses: host candidates name the device's own interfaces, server-reflexive candidates its public address as a STUN server sees it, and relay candidates the TURN server's address. Whoever receives your candidates learns them. RFC 8828 defines four IP handling modes and says browsers must not expose all interfaces (Mode 1) without user consent, which implementations may tie to camera and microphone permission. Chromium, for example, replaces host candidate addresses with generated `.local` names resolved over mDNS, a feature enabled by default in its source.

Your application decides the rest. The other peer always learns your public address from a server-reflexive or host candidate unless the connection is relay-only. Setting `iceTransportPolicy: 'relay'` in the `RTCPeerConnection` configuration makes the browser use only relay candidates, so peers see only your TURN server; use it where participants should not learn each other's address, such as anonymous calls or marketplace chats, and accept the bandwidth cost. Relay candidates also go only to authorized peers because the signaling server enforces room membership.

```js
// Credentials come from your server for this session; never hard-code them.
const {username, credential} = await (await fetch('/api/turn-credentials', {method: 'POST'})).json();

const pc = new RTCPeerConnection({
  iceServers: [{urls: ['turns:turn.example.test:5349?transport=tcp', 'turn:turn.example.test:3478'], username, credential}],
  // 'relay' hides both users' IP addresses from each other; 'all' (the default) allows direct paths.
  iceTransportPolicy: privacyMode ? 'relay' : 'all',
});
```

## DTLS-SRTP: fingerprints, cipher suites and keys

Browsers handle DTLS-SRTP themselves; your responsibility starts where a server terminates it, such as an SFU, a recording service or a gateway to telephony. RFC 8827 requires DTLS 1.2 support with `TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256` and the P-256 curve, the `SRTP_AES128_CM_HMAC_SHA1_80` protection profile, forward-secret cipher suites in preference, AEAD where possible, and no renegotiation. RFC 7714 defines the AEAD profiles such as `SRTP_AEAD_AES_128_GCM`. That is what ASVS 5.0 V17.2.2 means by approved cipher suites and a secure protection profile.

A server must also check the peer's certificate against the `a=fingerprint` attribute from the SDP and end the media stream if it does not match (V17.2.8), verify SRTP authentication on every packet so injected RTP is dropped (V17.2.3), and keep its own DTLS private key under your key-management policy (V17.2.1), like any TLS key (/domains/identity-crypto/key-management-pki/tls-pki-operations/). If a server generates a new certificate per call, the policy is mostly about not logging or exporting the key.

## Harden media servers against hostile packets

Media servers parse UDP from anyone who can reach their ports, before any application authentication. ASVS 5.0 asks that they keep processing media when they receive malformed SRTP (V17.2.4) or a flood from legitimate users (V17.2.5), that recording keeps working under such a flood (V17.2.7), and that they are not vulnerable to the DTLS ClientHello race (V17.2.6), in which an attacker who sends a ClientHello to the media port before the real peer breaks the session. FreeSWITCH's advisory for that race (CVE-2023-51443, fixed in 1.10.11) describes the denial of service and the fix: drop all packets from addresses that have not been validated by an ICE check. Ask the same of your server, or test it.

Run supported versions and subscribe to their advisories. Restrict the UDP port range in the firewall to what the server is configured to use. Keep administrative and control APIs on a private interface with real credentials: Janus, for example, ships its admin HTTP endpoint disabled (`admin_http = false`) and a sample `admin_secret` that you must change if you enable it. Authenticate the API your application uses to create rooms and tokens on the media server, store recordings as sensitive personal data with access control and retention limits, and fuzz custom packet handling (/domains/appsec/testing/parser-fuzzing/).

## ASVS 5.0 V17, requirement by requirement

The table maps each of the 12 requirements in ASVS 5.0.0 chapter V17 to the control in this guide and to a check you can run. Levels are ASVS's own: level 2 for most, level 3 for TURN port exhaustion, flood resilience, the ClientHello race and the fingerprint check.

| Requirement | Level | What to do | How to verify |
| --- | --- | --- | --- |
| V17.1.1 TURN only to non-reserved addresses | 2 | `denied-peer-ip` for every reserved IPv4 and IPv6 range; no `allow-loopback-peers` | `turnutils_uclient -e` to loopback, private and metadata addresses returns 403 |
| V17.1.2 TURN resists port exhaustion by legitimate users | 3 | `user-quota`, `total-quota`, `max-bps`, `bps-capacity`, short credential lifetimes | Load test with many allocations per user; others still allocate |
| V17.2.1 DTLS key managed per policy | 2 | Server DTLS keys under the TLS key-management policy; never logged or exported | Review key storage, rotation and access |
| V17.2.2 Approved DTLS cipher suites and SRTP profile | 2 | The RFC 8827 suite, forward secrecy, AEAD where supported, no NULL | Inspect the negotiated suite and profile in a test call |
| V17.2.3 SRTP authentication checked at the media server | 2 | SRTP auth verified on every packet | Inject altered RTP; it is dropped |
| V17.2.4 Media continues through malformed SRTP | 2 | Patched server; defensive packet parsing | Fuzz the media port during a call |
| V17.2.5 Media continues through an SRTP flood | 3 | Per-source rate limits; capacity headroom | Flood from a test client; other calls keep quality |
| V17.2.6 Not vulnerable to the DTLS ClientHello race | 3 | Drop packets from addresses ICE has not validated; patched version | Send an early ClientHello from another address; the call survives |
| V17.2.7 Recording continues through an SRTP flood | 3 | Recording isolated from the ingest path | Flood during a recorded call; the recording is complete |
| V17.2.8 DTLS certificate checked against the SDP fingerprint | 3 | Terminate media on mismatch | Alter the fingerprint in signaling; the call fails |
| V17.3.1 Signaling survives floods | 2 | Per-connection rate limit, connection limits | The flood test closes the flooding connection with 1008 |
| V17.3.2 Signaling survives malformed messages | 2 | Type allowlist, size limits, defensive parsing | Oversized, non-JSON and unknown-type messages are rejected |

## Pitfalls



- TURN credentials in a JavaScript bundle or mobile app, sometimes with no expiry at all.
- Treating an unguessable room link as authorization, so a forwarded link admits anyone.
- A signaling server that relays to any peer ID it is given, letting users message, ring or probe anyone.
- Trusting the `from` or `user` field in a signaling message instead of the authenticated connection.
- Running coturn with no `denied-peer-ip` lines inside a cloud VPC, which makes internal services and the metadata endpoint reachable.
- Publishing a media server's admin or monitoring API on a public interface with its sample secret.
- Logging full SDP and candidates indefinitely: they contain users' IP addresses (CWE-359).

## Guided practice

A telehealth start-up asks for an ASVS 5.0 V17 review before launch. Find the problems in the excerpts below, map each to a V17 requirement or to signaling authorization, and give the fix.

### Supplied material

```text
W1 public/js/call.js
    const pc = new RTCPeerConnection({iceServers: [{urls: 'turn:turn.clinic.example:3478',
      username: 'clinic', credential: 'Welcome2024!'}]});

W2 /etc/turnserver.conf on a VM in the clinic's cloud VPC (10.20.0.0/16)
    listening-port=3478
    lt-cred-mech
    user=clinic:Welcome2024!
    realm=clinic.example
    # no denied-peer-ip lines, no quotas

W3 signaling.js (WebSocket server)
    wss.on('connection', (ws, req) => {
      const room = new URL(req.url, 'http://x').searchParams.get('room');   // /ws?room=<appointment id>
      join(room, ws);
      ws.on('message', m => { const msg = JSON.parse(m); peers.get(msg.to)?.send(JSON.stringify(msg)); });
    });

W4 SFU configuration
    admin API listening on 0.0.0.0:7088, admin secret left at the sample value
    media server version: two minor releases behind, with an advisory for a DTLS ClientHello race

W5 Product requirement
    "Patients and clinicians should not see each other's home IP address."
```

### 1. W1 and W2: the TURN credentials

Explain what anyone can do with the credentials in W1 and how to replace them.

Worked answer: They are public and never expire: anyone can relay traffic through the clinic's TURN server. Switch coturn to `use-auth-secret` with a `static-auth-secret`, mint per-session credentials on the server after login, and rotate the shared secret.

### 2. W2: what the relay can reach

Name the addresses an attacker could reach through this TURN server and the requirement it fails.

Worked answer: With no `denied-peer-ip` lines it relays to the VPC (10.20.0.0/16), other private ranges and `169.254.169.254`, the cloud metadata service; that fails V17.1.1. Add the reserved IPv4 and IPv6 ranges, `no-multicast-peers`, and quotas for V17.1.2.

### 3. W3: signaling authorization

List the authorization problems in W3.

Worked answer: No authentication or Origin check at upgrade; the appointment ID in the URL is treated as authorization; messages are relayed to any `to` across rooms; the client controls `from`; no size or rate limits (V17.3.1, V17.3.2). Authenticate at upgrade, check that the user belongs to the appointment, assign peer IDs on the server and relay only within the room.

### 4. W4: the media server

Give the two fixes and the requirements they address.

Worked answer: Upgrade to the release that fixes the ClientHello race (V17.2.6, and it may carry other fixes for V17.2.4 and V17.2.5), and move the admin API to a private interface with a new secret, or disable it.

### 5. W5: IP address privacy

Decide how to meet the requirement and what it costs.

Worked answer: Set `iceTransportPolicy: 'relay'` so only TURN relay candidates are used and exchanged; both sides then see only the TURN server's address. All media flows through TURN, so size the relay capacity and keep the quotas.

Deliverable: A findings table with the excerpt, the ASVS 5.0 V17 requirement or authorization rule, the risk and the fix, and the order you would fix them in.

Scope: Paper exercise on fictional excerpts. Test TURN relays and media servers only on infrastructure you operate.

Articles and teaching material: CC BY 4.0. Original executable code: MIT. Attribution: Software Security.

## Sources

- [OWASP ASVS 5.0.0: V17 WebRTC](https://github.com/OWASP/ASVS/blob/v5.0.0_release/5.0/en/0x26-V17-WebRTC.md). Version 5.0.0.
- [RFC 8827: WebRTC Security Architecture](https://www.rfc-editor.org/rfc/rfc8827.html). RFC 8827 (January 2021).
- [RFC 8826: Security Considerations for WebRTC](https://www.rfc-editor.org/rfc/rfc8826.html). RFC 8826 (January 2021).
- [RFC 8828: WebRTC IP Address Handling Requirements](https://www.rfc-editor.org/rfc/rfc8828.html). RFC 8828 (January 2021).
- [RFC 8842: SDP Offer/Answer Considerations for DTLS and TLS](https://www.rfc-editor.org/rfc/rfc8842.html). RFC 8842 (January 2021).
- [RFC 5764: DTLS Extension to Establish Keys for SRTP](https://www.rfc-editor.org/rfc/rfc5764.html). RFC 5764 (May 2010).
- [RFC 7714: AES-GCM Authenticated Encryption in SRTP](https://www.rfc-editor.org/rfc/rfc7714.html). RFC 7714 (December 2015).
- [RFC 8656: Traversal Using Relays around NAT (TURN)](https://www.rfc-editor.org/rfc/rfc8656.html). RFC 8656 (February 2020).
- [RFC 9605: Secure Frame (SFrame)](https://www.rfc-editor.org/rfc/rfc9605.html). RFC 9605 (August 2024).
- [RFC 6455: The WebSocket Protocol, 10.2 Origin Considerations](https://www.rfc-editor.org/rfc/rfc6455.html#section-10.2). RFC 6455 (December 2011).
- [W3C WebRTC: Real-Time Communication in Browsers (RTCIceTransportPolicy)](https://www.w3.org/TR/webrtc/#rtcicetransportpolicy-enum). W3C Recommendation.
- [MDN browser-compat-data: RTCPeerConnection.setIdentityProvider](https://github.com/mdn/browser-compat-data/blob/main/api/RTCPeerConnection.json)
- [Chromium source: kWebRtcHideLocalIpsWithMdns feature](https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/common/features.cc)
- [IETF draft: A REST API for Access to TURN Services (draft-uberti-behave-turn-rest-00)](https://datatracker.ietf.org/doc/html/draft-uberti-behave-turn-rest-00). Expired draft, July 2013.
- [coturn: README.turnserver (TURN REST API, peer address options)](https://github.com/coturn/coturn/blob/master/README.turnserver). coturn 4.18.0.
- [coturn: example turnserver.conf](https://github.com/coturn/coturn/blob/master/examples/etc/turnserver.conf). coturn 4.18.0.
- [NVD: CVE-2020-26262 (coturn loopback relay bypass)](https://nvd.nist.gov/vuln/detail/CVE-2020-26262)
- [FreeSWITCH advisory GHSA-39gv-hq72-j6m6: DTLS hello race (CVE-2023-51443)](https://github.com/signalwire/freeswitch/security/advisories/GHSA-39gv-hq72-j6m6)
- [Janus WebRTC Server: sample HTTP transport configuration (admin endpoint)](https://github.com/meetecho/janus-gateway/blob/master/conf/janus.transport.http.jcfg.sample)