WebRTC has an unusually good security story at the transport layer. Encryption is mandatory, cannot be disabled, and is negotiated with modern primitives. Reading only that, teams conclude the security work is done and move on.
The problems are all one layer up. The threats that actually materialise in WebRTC products are a relay someone else is paying you to run, a signaling channel that quietly voids the encryption underneath it, an SFU that can read every frame it forwards, and an IP address disclosed to a page that never asked permission. This article is about those.
What the browser guarantees, and what it does not
Media is protected by SRTP; data channels ride SCTP over DTLS; keys come from a mandatory DTLS handshake between the peers. There is no unencrypted mode, no configuration flag, no legacy fallback — the older SDES scheme, which put keys in the SDP itself, was removed from browsers years ago precisely because it made confidentiality contingent on the signaling channel.
Except that it still is, and understanding why is the foundation for everything else here.
The fingerprint is the whole security model
The DTLS handshake proves that the peer you connected to holds the private key matching a particular certificate. It does not tell you whose certificate it is. That binding comes from one line in the SDP:
a=fingerprint:sha-256 D2:FA:0E:C3:22:59:5E:14:95:69:92:3D:13:B4:84:24
Your peer's browser hashes the certificate presented during the handshake and compares it to that line. Match, and the media is from the party your signaling channel identified. That is the entire chain of trust.
Now suppose an attacker can modify SDP in transit. They replace each side's fingerprint with their own, complete a separate DTLS handshake with each peer, and relay decrypted media between the two sessions. Both browsers report an encrypted connection, because both connections genuinely are encrypted — to the attacker.
An insecure or unauthenticated signaling channel therefore voids WebRTC's media encryption completely. Not weakens — voids. Every other item in this article is smaller than that one.
What follows from it:
- Signal over WSS or HTTPS. Never plaintext WS, not even "temporarily in staging."
- Authenticate the signaling connection, and authorise every message rather than only the handshake. A valid token does not mean this client belongs in this room.
- Treat the signaling server as security-critical infrastructure. It is the root of trust for every call it brokers, even though it never touches media.
Implementation detail for the signaling layer itself is in WebRTC signaling explained.
Your TURN server is an open proxy unless you close it
A TURN relay's entire function is forwarding traffic to an address a client names. That is a proxy. If you do not constrain which addresses may be named, you have deployed an open one on the public internet — and relays are scanned for continuously.
Two distinct problems follow.
Someone else's bandwidth bill, paid by you
Static credentials compiled into a web client are readable by every visitor in the network tab. Extracted, they let anyone relay arbitrary traffic through your server indefinitely. The fix is time-limited credentials issued per session by an authenticated endpoint:
// Server side only. The secret must never reach a browser bundle.
import crypto from 'node:crypto';
function turnCredentials(userId, secret, ttlSeconds = 3600) {
const expiry = Math.floor(Date.now() / 1000) + ttlSeconds;
const username = `${expiry}:${userId}`;
const credential = crypto.createHmac('sha1', secret).update(username).digest('base64');
return { username, credential, ttl: ttlSeconds };
}
Issue these from the same endpoint that authorises joining a call, so an unauthenticated user never receives working relay credentials at all.
A route into your private network
This one is more serious than a bandwidth bill and gets far less attention. Nothing stops a TURN client from requesting a permission for a private address. If your relay obliges, an attacker with valid credentials can reach whatever the relay can reach: internal services in your VPC, databases on private subnets, and — the one that ends badly — the cloud instance metadata endpoint at 169.254.169.254, which on a misconfigured instance hands out credentials.
coturn will do this by default. Deny the ranges explicitly:
# /etc/turnserver.conf
no-multicast-peers
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=127.0.0.0-127.255.255.255
denied-peer-ip=169.254.0.0-169.254.255.255 # link-local: cloud metadata
denied-peer-ip=172.16.0.0-172.31.255.255
denied-peer-ip=192.168.0.0-192.168.255.255
Add quotas so a single credential cannot exhaust the server, remembering that user-quota is per user and total-quota is server-wide, and that max-bps is bytes per second per session:
user-quota=12
total-quota=1200
max-bps=250000 # ≈ 2 Mbps per session
Full deployment detail is in setting up a coturn server.
One thing a relay is not a risk to: the confidentiality of the media passing through it. Relayed packets are SRTP, encrypted with keys derived between the peers, and the relay never holds them. Using a third-party TURN provider is a decision about bandwidth, availability, and traffic metadata — not about who can watch your calls.
Encryption is hop-by-hop, and your SFU is a hop
WebRTC's guarantee is between DTLS endpoints. In a two-party call those are the two browsers, so the call is end-to-end encrypted. In a group call routed through an SFU — which is nearly every group call — the endpoints are browser-to-SFU and SFU-to-browser. The SFU decrypts, inspects, and re-encrypts.
This is not a flaw; it is what lets the SFU do its job of selecting layers and forwarding streams. But it means the common claim that "WebRTC is end-to-end encrypted" is false for the topology most products actually run, and if you have made that claim in your marketing or your compliance documentation, it is worth checking.
Real end-to-end encryption through an SFU requires encrypting frames before they reach the media stack, using Encoded Transforms (the RTCRtpScriptTransform API, and Insertable Streams in older Chrome), typically with the SFrame framing designed for this. The server then forwards ciphertext it cannot read.
Be clear-eyed about the cost before committing. You take on key distribution and rotation as room membership changes, you lose server-side recording and transcription, and browser support for the transform APIs is uneven enough that you will be writing fallbacks. Worth it for a product whose premise is confidentiality; overkill for most.
The IP disclosure that needs no permission
Any page can construct an RTCPeerConnection and start ICE gathering without prompting the user. The candidates that come back describe the user's network. This is inherent to how WebRTC works, and it has been used for tracking and for de-anonymising VPN users.
Browsers have narrowed it, and it is worth knowing precisely what is and is not covered:
| Exposed | Status |
|---|---|
| Local/LAN addresses | Largely mitigated — host candidates are replaced with randomised .local mDNS names for pages without media permission |
| Public IP address | Still exposed if any STUN server is configured — that is what an srflx candidate is |
| Number of network interfaces | Partially inferable from the candidate set |
So mDNS obfuscation solved the LAN-scanning problem, not the public-IP problem. There is no way to establish a peer connection without revealing an address that reaches you, because that is the point of the exercise.
What you can do as a developer:
- Only construct a peer connection when the user has initiated something that needs one. Speculatively creating one at page load is a real privacy cost with no benefit.
- If your product's threat model requires it,
iceTransportPolicy: 'relay'reveals only your TURN server's address to the far peer. It also routes 100% of calls through the relay, so the bandwidth cost is severe — a deliberate trade, never a default. - Constrain WebRTC in embedded content with a Permissions-Policy header:
Permissions-Policy: camera=(), microphone=(), display-capture=().
Capture: permissions, indicators, and screen sharing
The browser handles consent for camera and microphone, and handles it well — secure context required, explicit prompt, persistent hardware indicator the page cannot suppress. Your responsibilities are narrower but real:
- Request at the moment of use. Prompting on page load produces denials, and a denial is sticky — the user must dig into site settings to reverse it. Ask when they click "join."
- Stop tracks when you are finished.
stream.getTracks().forEach(t => t.stop()). Leaving them live keeps the camera indicator on after a call ends, which users read — correctly — as being recorded. - Handle denial as a normal path. An audio-only fallback is better product design than an error page.
Screen sharing via getDisplayMedia() deserves separate care because the failure mode is human rather than technical. The user chooses what to share, and they routinely choose an entire screen containing a password manager, a Slack notification, or a customer record. Default the constraint to a single window, show a clear persistent indicator of what is being shared, and make stopping it a one-click action that is always visible.
A checklist worth actually running
Ordered by how much damage the omission causes, not by how hard each is:
- Signaling is over WSS/HTTPS, authenticated at connect and authorised per message.
- TURN credentials are time-limited and issued server-side; no static credential exists in any client bundle.
- The TURN server denies private, loopback, and link-local peer ranges —
169.254.0.0/16especially. - TURN quotas and per-session bandwidth caps are set.
- Room membership is verified on the server for every signaling message, not trusted from the client.
- Any claim you make about "end-to-end encryption" matches your actual topology.
- Media permissions are requested at point of use, and tracks are stopped when the call ends.
- Screen sharing defaults to a window and shows a persistent, dismissible indicator.
- Signaling endpoints are rate limited.
- TLS certificates on the TURN server have a renewal hook that restarts coturn — an expired certificate breaks
turns:silently, andturns:is the path your most restricted users depend on.
That last item is the one most likely to be missing, because it does not fail until roughly ninety days after everything was verified working. It is covered in the coturn guide.
To confirm items 2 and 10 are genuinely in place, the check is whether your TURN and TURNS endpoints still return a relay candidate today — from a real browser, on a real network. The tester on this site does that without your credentials ever leaving the browser, which is the only sensible way to test a secret.
Check Your Own Servers
Locked-down TURN credentials and access rules are only safe if they still work. After hardening, run your
turn:/turns: servers through the tester to confirm authentication succeeds and no
relay is left open to anonymous use.