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.

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.
Applies to
  • WebRTC (W3C and IETF RTCWEB) W3C WebRTC Recommendation; RFC 8825 to RFC 8834
  • coturn 4.18.0
  • ws (Node.js) 8.22.0
  • OWASP ASVS 5.0.0, chapter V17

Browser and native applications that use WebRTC for audio, video, screen sharing or data channels, with your own signaling service and your own or hosted TURN and media servers.

Part of
Learning path: Browser and API boundaries (step 18 of 18). Workflow: Test an application

Fix it in your stack

Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

Learning goals and prerequisitesAbout 60 minutes: 25 to read, 15 to run the signaling and coturn tests and 20 for the exercise

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.

Before you start

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.

  • 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.

Refresh first: Apply security limits to GraphQL, WebSocket and gRPC operations

Go to the guided practice

Where trust is decided in a WebRTC call

Your infrastructureAlice's browserSignaling serverTURN serverBob's browserWebSocket with ticketand Origin1join room, offer witha=fingerprint2offer, from = server-assigned ID2TURN username andcredential3Allocate, CreatePermission(peer)4relayed DTLS-SRTP5Trust boundaryUntrusted input1Step, described below
  1. Authenticate the signaling connectionThe page fetches a one-time ticket over HTTPS and opens the WebSocket with it; the server checks the ticket and the Origin before upgrading.
  2. Authorize the room and relay the offerThe server checks that the user may join the room, stamps the offer with the server-assigned sender ID and delivers it only to a member of the same room. The offer carries the DTLS fingerprint.
  3. Get relay credentialsEach browser receives TURN credentials that expire within the hour, minted from a secret shared with the TURN server.
  4. Relay to peers, never inwardThe TURN server allocates a relay address and refuses permissions to private, loopback and metadata addresses.
  5. Encrypt and verify the mediaThe peers run DTLS over the ICE path, check each other's certificate against the fingerprint from signaling and derive SRTP keys.
Whoever can change the signaling messages can replace the fingerprints, so signaling authentication and authorization protect the media too.

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.

Fixedsignaling.jsJavaScript
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.

Fixedturn_credentials.pyPython
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.

Configurationturnserver.confINI
# 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.

Testprobe.shShell
#!/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.

Configurationcall.jsJavaScript
// 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.

ASVS 5.0 V17, requirement by requirement
RequirementLevelWhat to doHow to verify
V17.1.1 TURN only to non-reserved addresses2denied-peer-ip for every reserved IPv4 and IPv6 range; no allow-loopback-peersturnutils_uclient -e to loopback, private and metadata addresses returns 403
V17.1.2 TURN resists port exhaustion by legitimate users3user-quota, total-quota, max-bps, bps-capacity, short credential lifetimesLoad test with many allocations per user; others still allocate
V17.2.1 DTLS key managed per policy2Server DTLS keys under the TLS key-management policy; never logged or exportedReview key storage, rotation and access
V17.2.2 Approved DTLS cipher suites and SRTP profile2The RFC 8827 suite, forward secrecy, AEAD where supported, no NULLInspect the negotiated suite and profile in a test call
V17.2.3 SRTP authentication checked at the media server2SRTP auth verified on every packetInject altered RTP; it is dropped
V17.2.4 Media continues through malformed SRTP2Patched server; defensive packet parsingFuzz the media port during a call
V17.2.5 Media continues through an SRTP flood3Per-source rate limits; capacity headroomFlood from a test client; other calls keep quality
V17.2.6 Not vulnerable to the DTLS ClientHello race3Drop packets from addresses ICE has not validated; patched versionSend an early ClientHello from another address; the call survives
V17.2.7 Recording continues through an SRTP flood3Recording isolated from the ingest pathFlood during a recorded call; the recording is complete
V17.2.8 DTLS certificate checked against the SDP fingerprint3Terminate media on mismatchAlter the fingerprint in signaling; the call fails
V17.3.1 Signaling survives floods2Per-connection rate limit, connection limitsThe flood test closes the flooding connection with 1008
V17.3.2 Signaling survives malformed messages2Type allowlist, size limits, defensive parsingOversized, 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 scenario material
Plain 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. 1. W1 and W2: the TURN credentials

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

    Expected result and reasoning

    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. 2. W2: what the relay can reach

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

    Expected result and reasoning

    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. 3. W3: signaling authorization

    List the authorization problems in W3.

    Expected result and reasoning

    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. 4. W4: the media server

    Give the two fixes and the requirements they address.

    Expected result and reasoning

    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. 5. W5: IP address privacy

    Decide how to meet the requirement and what it costs.

    Expected result and reasoning

    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.

Your 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.

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

Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.

No draft saved on this device yet.

Sources & further reading

The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.

  1. OWASP ASVS 5.0.0: V17 WebRTC. Version 5.0.0.
  2. RFC 8827: WebRTC Security Architecture. RFC 8827 (January 2021).
  3. RFC 8826: Security Considerations for WebRTC. RFC 8826 (January 2021).
  4. RFC 8828: WebRTC IP Address Handling Requirements. RFC 8828 (January 2021).
  5. RFC 8842: SDP Offer/Answer Considerations for DTLS and TLS. RFC 8842 (January 2021).
  6. RFC 5764: DTLS Extension to Establish Keys for SRTP. RFC 5764 (May 2010).
  7. RFC 7714: AES-GCM Authenticated Encryption in SRTP. RFC 7714 (December 2015).
  8. RFC 8656: Traversal Using Relays around NAT (TURN). RFC 8656 (February 2020).
  9. RFC 9605: Secure Frame (SFrame). RFC 9605 (August 2024).
  10. RFC 6455: The WebSocket Protocol, 10.2 Origin Considerations. RFC 6455 (December 2011).
  11. W3C WebRTC: Real-Time Communication in Browsers (RTCIceTransportPolicy). W3C Recommendation.
  12. MDN browser-compat-data: RTCPeerConnection.setIdentityProvider
  13. Chromium source: kWebRtcHideLocalIpsWithMdns feature
  14. IETF draft: A REST API for Access to TURN Services (draft-uberti-behave-turn-rest-00). Expired draft, July 2013.
  15. coturn: README.turnserver (TURN REST API, peer address options). coturn 4.18.0.
  16. coturn: example turnserver.conf. coturn 4.18.0.
  17. NVD: CVE-2020-26262 (coturn loopback relay bypass)
  18. FreeSWITCH advisory GHSA-39gv-hq72-j6m6: DTLS hello race (CVE-2023-51443)
  19. Janus WebRTC Server: sample HTTP transport configuration (admin endpoint)

Reference information

Checked against ASVS 5.0.0, RFC 8826, 8827, 8828, 8842, 5764, 8489 and 8656, the W3C WebRTC Recommendation, coturn 4.18.0 and the Janus Gateway sample configuration. The signaling tests ran on Node.js 26.11.1 with ws 8.22.0, the credential tests on Python 3.14.8, and the TURN tests in coturn/coturn:4.18.0 with --network=none.

ASVS 5.0.0 requirements
v5.0.0-17.1.1 · v5.0.0-17.1.2 · v5.0.0-17.2.1 · v5.0.0-17.2.2 · v5.0.0-17.2.3 · v5.0.0-17.2.4 · v5.0.0-17.2.5 · v5.0.0-17.2.6 · v5.0.0-17.2.7 · v5.0.0-17.2.8 · v5.0.0-17.3.1 · v5.0.0-17.3.2

Download this guide as Markdown