What you'll learn
Quick Answer
WebRTC lets two browsers exchange audio, video, or raw data directly over the network — but that only happens after a handshake, and the handshake still needs a signaling server to exchange connection details. STUN discovers each side's public address, ICE finds a working network path using that information, TURN relays traffic when a direct path turns out to be impossible, and DTLS-SRTP encrypts every media stream by mandate, with no opt-out. "No server in the middle" describes the media path once it's set up, not the setup itself.
What "No Server in the Middle" Actually Means
WebRTC's headline feature is peer-to-peer media: once a connection is established, your camera and microphone data can flow directly to the other participant's browser without passing through an application server. But two browsers on two different home networks generally can't just find each other on their own — most consumer and office networks sit behind NAT (Network Address Translation), which means the device has a private IP address that the outside internet has no route to. Before any media can flow, both sides need to exchange some setup information: what media they support, and how to actually reach each other's network.
That exchange is called signaling, and here's the detail most WebRTC explainers gloss over: WebRTC deliberately does not standardize signaling at all. The W3C WebRTC 1.0 specification (a Recommendation since January 2021) and its companion IETF documents — RFC 8825 (overview), RFC 8826 and RFC 8827 (security), RFC 8829 (the JavaScript Session Establishment Protocol, JSEP) — define what happens once two peers have exchanged connection information, not how they exchange it. In practice almost everyone uses a WebSocket connection to a small signaling server for this, but it could just as easily be plain HTTP polling, or even two people manually pasting text into a chat window — WebRTC genuinely does not care, as long as the data gets from one side to the other somehow.
So the accurate version of the pitch is: the signaling server is real and necessary, but it only ever sees connection metadata — codecs supported, network addresses, encryption fingerprints — never your actual audio or video content. Once ICE (covered below) finds a usable path, media flows directly between the two peers, or through a relay server if a direct path isn't possible — and even that relay never decrypts the media it forwards.
Signaling and the SDP Offer/Answer Exchange
The actual content exchanged during signaling is called an SDP (Session Description Protocol) offer and answer. One side calls createOffer() on its RTCPeerConnection, which generates a text description of what it wants to send and receive — supported audio/video codecs, resolution constraints, and a cryptographic fingerprint used later for the encryption handshake. That description is set as the local description with setLocalDescription() and sent to the other peer over whatever signaling channel the app is using.
The receiving side takes that offer, sets it as its remote description with setRemoteDescription(), then calls createAnswer() to generate its own SDP describing what it's willing to accept, sets that as its own local description, and sends it back over the same signaling channel. The first side receives the answer and sets it as its remote description. At this point both sides agree on what media formats will be used — but they still don't have a network path to each other, which is where ICE comes in.
// Illustrative reference code — standard WebRTC API surface, not executed
// in this environment (no browser runtime available here).
const pc = new RTCPeerConnection({ iceServers: [...] });
// Caller side
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
sendToSignalingServer({ type: "offer", sdp: pc.localDescription });
// Callee side, after receiving the offer over signaling
await pc.setRemoteDescription(receivedOffer);
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
sendToSignalingServer({ type: "answer", sdp: pc.localDescription });
// Caller side, after receiving the answer
await pc.setRemoteDescription(receivedAnswer);
ICE, NAT Traversal, and Candidate Types
ICE (Interactive Connectivity Establishment) is the process that actually finds a working network path once both sides know each other's media capabilities. Each browser gathers a list of candidates — possible address:port pairs where it might be reachable — in phases, and sends them to the other peer over the signaling channel as they're discovered (this is why you'll see onicecandidate fire multiple times in real code, not just once).
- Host candidates come straight from the device's own network interfaces — its local Wi-Fi or Ethernet IP address. These work when both peers happen to be on the same local network.
- Server-reflexive candidates come from asking a STUN server "what address and port does the outside world see me as?" — this is how a device behind NAT discovers its own public-facing address.
- Peer-reflexive candidates are discovered during the connectivity checks themselves, when a check arrives from an address that wasn't in the original candidate list.
- Relay candidates come from a TURN server and represent a fallback address on a relay server, used only when no direct path can be found.
Once both sides have exchanged candidate lists, ICE runs connectivity checks: it pairs up local and remote candidates in priority order (host pairs tried before reflexive, reflexive before relay) and sends a STUN binding request across each pair. The first pair that gets a successful response — or the best-priority working pair — is selected as the active path for media. This is why a WebRTC connection can take a second or two to establish even on a fast network: it's genuinely testing multiple possible paths before picking one.
STUN vs. TURN: When Each One Gets Used
STUN (Session Traversal Utilities for NAT) is deliberately lightweight: a device sends a small request to a public STUN server, and the server replies with the address and port it saw the request arrive from. That's the device's public-facing address as seen from outside its own NAT — information the device has no way to know on its own. STUN never touches the actual call's media; its only job is address discovery, which is why STUN servers are cheap to run and Google and others have historically offered public ones for free.
STUN alone isn't enough for every network, though. Some NAT configurations (particularly symmetric NAT, common on some corporate and mobile carrier networks) map a different external port for every destination a device talks to, which breaks the assumption that the address discovered via STUN will work for a connection to a different peer. Some firewalls block direct peer-to-peer UDP traffic outright. For those cases, TURN (Traversal Using Relays around NAT) provides a fallback: instead of just reporting an address, a TURN server actively relays every packet of the call, byte for byte, between the two peers. This makes TURN meaningfully more expensive to operate than STUN, since its bandwidth cost scales directly with call minutes rather than being a handful of small discovery requests — which is exactly why production WebRTC deployments budget for and run (or pay for) a real TURN service rather than assuming STUN alone will get every user connected.
Encryption Is Not Optional: DTLS-SRTP
Unlike signaling, media encryption in WebRTC is mandatory by specification, with no unencrypted fallback. RFC 8827 (the WebRTC Security Architecture) requires DTLS-SRTP for every media stream — if an implementation somehow offered the older SDES key-exchange mechanism alongside it, DTLS-SRTP must still be the one selected. Practically, this means there is no way to configure a WebRTC connection to send audio or video in the clear.
The mechanics: once ICE has established a working candidate pair, the two peers perform a DTLS (Datagram TLS) handshake directly over that same path to derive shared encryption keys, using the cryptographic fingerprint that was exchanged earlier inside the SDP offer/answer. Those keys are then used to encrypt the actual audio and video packets with SRTP (Secure Real-time Transport Protocol). So even when a call has to fall back to relaying through a TURN server because no direct path exists, the TURN server is still only forwarding encrypted SRTP packets — it has the keys for none of it, and cannot see the content of the call.
The one piece WebRTC explicitly leaves unencrypted by its own specification is the signaling channel itself — that's an application-layer responsibility. If you build a signaling server on plain HTTP and unencrypted WebSocket, an attacker on the network path could see (and in principle tamper with) the SDP and candidate exchange, which is why every real deployment puts signaling behind HTTPS and WSS (WebSocket Secure), not because WebRTC requires it directly, but because nothing else is protecting that channel.
Putting It Together: A Full Connection Walkthrough
Here's the complete sequence, in order, for two browsers starting a call:
- Both browsers connect to an application-chosen signaling channel (commonly a WebSocket to a small server) — this step has nothing to do with the WebRTC spec itself.
- The caller creates an
RTCPeerConnection, generates an SDP offer withcreateOffer(), and sends it over signaling. - The callee receives the offer, sets it as the remote description, generates an SDP answer with
createAnswer(), and sends it back over signaling. - Both sides begin gathering ICE candidates (host, then server-reflexive via STUN, then relay via TURN if configured) and exchange them over the same signaling channel as they're discovered.
- ICE runs connectivity checks across candidate pairs and selects the best working path.
- The two peers perform a DTLS handshake over that path to establish encryption keys.
- Media starts flowing as SRTP packets over the selected path — directly between the peers if a host or server-reflexive pair worked, or through the TURN relay if that was the only option that succeeded.
// Illustrative reference code for step 4-5 — not executed here.
const pc = new RTCPeerConnection({
iceServers: [
{ urls: "stun:stun.example.com:3478" },
{ urls: "turn:turn.example.com:3478", username: "user", credential: "pass" }
]
});
pc.onicecandidate = (event) => {
if (event.candidate) {
sendToSignalingServer({ type: "ice-candidate", candidate: event.candidate });
}
};
pc.ontrack = (event) => {
remoteVideoElement.srcObject = event.streams[0];
};Every step above is a genuine part of establishing a call — none of it is optional, and the whole reason WebRTC feels instant to end users despite all this machinery is that it typically completes in well under a second on a normal network.
