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.1untildenied-peer-ipranges 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.
| 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 |
- Open the signaling WebSocket without a ticket or cookie, with a reused ticket and with a foreign
Origin; each upgrade must be refused. - As a user without access, send
joinfor 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. - Send an oversized frame and a burst of messages; the server must close the connection and keep serving other users.
- Inspect the client code and configuration for static TURN credentials; minted ones must expire within your lifetime limit.
- From a test client, request TURN permissions to
127.0.0.1,10.0.0.1,169.254.169.254,::1andfd00:ec2::254; all must be refused. - In
chrome://webrtc-internalsorabout:webrtc, confirm the negotiated DTLS suite and SRTP profile, and that relay-only mode shows only relay candidates. - 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
Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.
Learning goals and prerequisites
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 practiceWhere trust is decided in a WebRTC call
- 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.
- 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.
- Get relay credentialsEach browser receives TURN credentials that expire within the hour, minted from a secret shared with the TURN server.
- Relay to peers, never inwardThe TURN server allocates a relay address and refuses permissions to private, loopback and metadata addresses.
- 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.
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 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.
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};
}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.
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};
# }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.
# 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-logRepeat 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.
#!/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 $failICE 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.
// 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',
});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.
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/).
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 |
- 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
fromoruserfield in a signaling message instead of the authenticated connection. - Running coturn with no
denied-peer-iplines 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
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.
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-secretwith astatic-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.
Expected result and reasoning
With no
denied-peer-iplines it relays to the VPC (10.20.0.0/16), other private ranges and169.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.
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
toacross rooms; the client controlsfrom; 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.
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. 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.
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.
- OWASP ASVS 5.0.0: V17 WebRTC. Version 5.0.0.
- RFC 8827: WebRTC Security Architecture. RFC 8827 (January 2021).
- RFC 8826: Security Considerations for WebRTC. RFC 8826 (January 2021).
- RFC 8828: WebRTC IP Address Handling Requirements. RFC 8828 (January 2021).
- RFC 8842: SDP Offer/Answer Considerations for DTLS and TLS. RFC 8842 (January 2021).
- RFC 5764: DTLS Extension to Establish Keys for SRTP. RFC 5764 (May 2010).
- RFC 7714: AES-GCM Authenticated Encryption in SRTP. RFC 7714 (December 2015).
- RFC 8656: Traversal Using Relays around NAT (TURN). RFC 8656 (February 2020).
- RFC 9605: Secure Frame (SFrame). RFC 9605 (August 2024).
- RFC 6455: The WebSocket Protocol, 10.2 Origin Considerations. RFC 6455 (December 2011).
- W3C WebRTC: Real-Time Communication in Browsers (RTCIceTransportPolicy). W3C Recommendation.
- MDN browser-compat-data: RTCPeerConnection.setIdentityProvider
- Chromium source: kWebRtcHideLocalIpsWithMdns feature
- IETF draft: A REST API for Access to TURN Services (draft-uberti-behave-turn-rest-00). Expired draft, July 2013.
- coturn: README.turnserver (TURN REST API, peer address options). coturn 4.18.0.
- coturn: example turnserver.conf. coturn 4.18.0.
- NVD: CVE-2020-26262 (coturn loopback relay bypass)
- FreeSWITCH advisory GHSA-39gv-hq72-j6m6: DTLS hello race (CVE-2023-51443)
- 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.
- SDLC phase
- DesignDevelopment
- 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