Security
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. See the section bibliography for the reference material consulted while preparing these pages. |
A WebSocket is a long-lived, bidirectional channel that the browser opens with the visitor’s cookies and that bypasses the same-origin policy. A WebRTC call exposes network addresses and, through TURN, turns a server into a proxy. An XMPP server accepts accounts from strangers and talks to other servers. Each of these has a failure mode that a plain request/response API does not, and each is cheap to prevent when it is designed in. This page collects the controls for all three, shows how to set them on every server stack of this section, and ends with a checklist.
The page was written against RFC 6455 (WebSocket), RFC 7692 (permessage-deflate), RFC 8656 (TURN), the OWASP WebSocket
Security Cheat Sheet, ws 8.22.0, socket.io 4.8.4, Channels 4.3.2 with Daphne 4.2.3, coturn 4.18.0 and nginx 1.28.
Everything was checked on 2026-10-24. The authentication side (tickets, token expiry, per-room authorization) is on
Authentication and Authorization; the wire details
are on The WebSocket Protocol; TURN itself is on
ICE, STUN and TURN; the frame limits come from the Chatterbox
protocol of Message Design and Serialization.
Threat model
| Asset | Threat | Main control |
|---|---|---|
A signed-in user’s session |
Cross-Site WebSocket Hijacking (CSWSH): another website opens a socket to your server and the browser attaches your cookies |
|
Traffic in transit |
Eavesdropping or tampering on |
|
Other users' browsers |
Stored or reflected XSS delivered through chat text, usernames or room names |
Output encoding, no |
Server memory and CPU |
Huge frames, floods, thousands of idle sockets, slow readers, compression bombs |
|
Message secrets |
A compression length oracle (CRIME and BREACH style) |
Compression off, or never mix secrets with attacker-controlled text |
Participants of a call |
Address exposure, unwanted camera or microphone access |
|
The TURN server and its network |
An authenticated client asks the relay to forward packets to internal hosts (SSRF), or uses it as a free bandwidth source |
|
An XMPP server |
Registration spam, open federation abuse, s2s floods |
Closed registration, rate limits, allow-listed federation |
Cross-Site WebSocket Hijacking
Browsers do not apply the same-origin policy to WebSocket handshakes, and CORS does not govern them either: a page on
https://evil.example can run new WebSocket("wss://chat.example.test/ws"), and the browser sends the handshake with
the cookies it holds for chat.example.test, adding the header Origin: https://evil.example. If the server accepts the
connection and treats the cookie as proof of identity, the attacker’s script now speaks as the visitor: it can read every
message and send new ones. That is CSWSH, the WebSocket form of CSRF. RFC 6455 section 10.2 therefore tells servers to
check the Origin header, and the OWASP cheat sheet asks for an explicit allow-list with no wildcard and no substring
matching (https://chat.example.test.evil.example contains the allowed name).
Three facts decide how to design the check:
-
A browser always sends
Originon a WebSocket handshake and a page cannot change it. The check is a real defence against hostile web pages. -
A non-browser client (a script,
curl, a mobile app, a Node.js process) can send anyOriginor none. The check says nothing about them, so it never replaces authentication. Chatterbox accepts a handshake with noOriginand makes such clients prove themselves with a one-time ticket in thehelloframe. -
Originis checked on the handshake only. A single-page app that sends its token in the first message (not in a cookie) is immune to CSWSH even without the check, because the attacker’s page cannot obtain the token. Do both.
Compare strings exactly, normalised to scheme, host and port (https://chat.example.test, no trailing slash). Keep the
list in configuration, not in code, because the development origin (http://localhost:3000) differs from production.
Never reflect the incoming Origin into an allow decision, and never use * together with cookie authentication.
SameSite=Lax or Strict on the session cookie is a second line of defence: a cross-site handshake then carries no
cookie. It cannot replace the allow-list, because it does not cover sibling subdomains that share the site.
A complete guarded endpoint
The following Node.js server uses the ws library and applies every limit of this page in one file: the Origin
allow-list on the HTTP upgrade event (before any WebSocket exists), the 16 KiB frame limit that produces close code
1009, a connection cap per address, a token-bucket rate limit that escalates to close code 4429, a text-frames-only rule,
a slow-consumer guard and perMessageDeflate off.
// guarded-server.mjs - a hardened Chatterbox endpoint on the ws library
import { createServer } from "node:http";
import { WebSocketServer } from "ws";
const ALLOWED_ORIGINS = new Set((process.env.ORIGINS ?? "https://chat.example.test").split(","));
const MAX_FRAME = 16384; // Chatterbox maxFrameBytes
const MAX_PER_IP = 4; // concurrent sockets per client address
const RATE = 20, BURST = 40; // envelopes per second, bucket size
const MAX_BUFFERED = 1 << 20; // slow-consumer threshold: 1 MiB queued for one socket
const perIp = new Map();
const server = createServer((req, res) => { res.writeHead(404).end(); });
const wss = new WebSocketServer({
noServer: true,
maxPayload: MAX_FRAME, // larger frames close the socket with 1009
perMessageDeflate: false, // no compression context to attack, no per-socket zlib memory
handleProtocols: (protocols) => (protocols.has("chatterbox.v1") ? "chatterbox.v1" : false),
});
function reject(socket, status, text) {
socket.write(`HTTP/1.1 ${status} ${text}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
socket.destroy();
}
server.on("upgrade", (req, socket, head) => {
const origin = req.headers.origin;
// Browsers always send Origin; other clients may omit it and must authenticate in the first frame.
if (origin !== undefined && !ALLOWED_ORIGINS.has(origin)) return reject(socket, 403, "Forbidden");
const ip = req.socket.remoteAddress ?? "unknown";
if ((perIp.get(ip) ?? 0) >= MAX_PER_IP) return reject(socket, 429, "Too Many Requests");
wss.handleUpgrade(req, socket, head, (ws) => {
perIp.set(ip, (perIp.get(ip) ?? 0) + 1);
ws.on("close", () => perIp.set(ip, perIp.get(ip) - 1));
ws.on("error", () => ws.terminate()); // ws emits 'error' for a 1009 frame; without a listener the process crashes
wss.emit("connection", ws, req);
});
});
wss.on("connection", (ws) => {
let tokens = BURST, last = Date.now(), strikes = 0;
ws.on("message", (data, isBinary) => {
if (isBinary) return ws.close(4400, "text frames only");
const now = Date.now();
tokens = Math.min(BURST, tokens + ((now - last) / 1000) * RATE);
last = now;
if (tokens < 1) {
ws.send(JSON.stringify({ v: 1, type: "error", payload: { code: "rate_limited", retryable: true } }));
if (++strikes >= 5) ws.close(4429, "rate limited");
return;
}
tokens -= 1;
let env;
try { env = JSON.parse(data.toString("utf8")); } catch { return ws.close(4400, "bad json"); }
if (env?.v !== 1 || typeof env.type !== "string") return ws.close(4400, "bad envelope");
if (ws.bufferedAmount > MAX_BUFFERED) return ws.close(1013, "slow consumer");
ws.send(JSON.stringify({ v: 1, type: "ack", payload: { ref: env.id, ok: true } }));
});
});
server.listen(Number(process.env.PORT ?? 18200), "127.0.0.1", () => console.log("listening"));
Source: ws API documentation
A probe that connects from outside shows each guard working:
// probe.mjs - exercises each guard from outside
import WebSocket from "ws";
const URL = "ws://127.0.0.1:18200/ws";
const open = (origin, extra = {}) => new Promise((resolve) => {
const ws = new WebSocket(URL, "chatterbox.v1", { headers: origin ? { Origin: origin } : {}, ...extra });
ws.on("open", () => resolve({ ws, ok: true }));
ws.on("unexpected-response", (_q, res) => resolve({ ok: false, status: res.statusCode }));
ws.on("error", () => {});
});
const closed = (ws) => new Promise((r) => ws.on("close", (code, reason) => r({ code, reason: reason.toString() })));
let r = await open("https://chat.example.test"); console.log("allowed origin ->", r.ok ? "101 open" : r.status); r.ws.close();
r = await open("https://evil.example"); console.log("foreign origin ->", r.ok ? "101 open" : r.status);
r = await open(null); console.log("no Origin ->", r.ok ? "101 open" : r.status); r.ws.close();
r = await open("https://chat.example.test");
const c = closed(r.ws);
r.ws.send("x".repeat(16385));
console.log("16385-byte frame ->", await c);
const socks = [];
for (let i = 0; i < 6; i++) { const o = await open("https://chat.example.test"); socks.push(o); }
console.log("6 sockets from one IP ->", socks.map((s) => (s.ok ? "open" : s.status)).join(","));
socks.forEach((s) => s.ws?.close());
r = await open("https://chat.example.test");
const c2 = closed(r.ws); let errors = 0;
r.ws.on("message", (d) => { if (JSON.parse(d).type === "error") errors++; });
for (let i = 0; i < 200; i++) r.ws.send(JSON.stringify({ v: 1, type: "presence", id: String(i), payload: {} }));
console.log("200-envelope burst ->", await c2, "rate_limited errors:", errors);
process.exit(0);
Source: ws API documentation
Running the server and then the probe prints:
allowed origin -> 101 open
foreign origin -> 403
no Origin -> 101 open
16385-byte frame -> { code: 1009, reason: '' }
6 sockets from one IP -> open,open,open,open,429,429
200-envelope burst -> { code: 4429, reason: 'rate limited' } rate_limited errors: 5
Points worth noticing in the output and the code:
-
The foreign origin is refused with plain HTTP 403 before the upgrade, so no frame is ever exchanged.
-
The oversized frame closes the socket with 1009 (Message Too Big). The
wslibrary also emits anerrorevent on the server-side socket for that frame; without anerrorlistener the event is thrown and the whole process crashes, which turns a single bad client into a denial of service. -
The limit counts the bytes of one message.
maxPayloadis checked while the frame is received, so the server never buffers more than the limit. The library default is 100 MiB, which is far too much for chat. -
A flood first gets
rate_limitederrors (retryable) and only a persistent one is disconnected with 4429, matching the Chatterbox rules of the protocol page. -
The per-address cap uses
remoteAddress. Behind a proxy that is the proxy’s address; take the client address from a trustedX-Forwarded-Forhop only (see Reverse proxies).
Origin checks per stack
Every server of this section has a switch for this. The table names it and the page that runs it.
| Stack | Where the Origin allow-list lives | Notes |
|---|---|---|
|
Check |
|
Socket.IO (Node.js) |
|
The |
python-socketio |
|
|
Django Channels |
|
|
SignalR |
CORS for the negotiate request plus |
|
Spring WebFlux |
A |
|
uWebSockets.js |
Check the |
|
Laravel Reverb |
|
Change the default. Laravel Reverb |
Swoole |
Read the |
|
Atmosphere |
An interceptor or a servlet filter on the upgrade request. |
|
XMPP servers |
Server-specific |
Socket.IO
allowRequest is the right hook because it sees the Origin of the first polling request and of the upgrade. The
Socket.IO cors option is shown below together with it to make the difference visible: with only cors, a client on a
foreign origin that speaks WebSocket directly is still admitted.
import { createServer } from "node:http";
import { Server } from "socket.io";
const allowed = new Set(["https://chat.example.test"]);
const httpServer = createServer();
const io = new Server(httpServer, {
cors: { origin: [...allowed], credentials: true }, // headers for the polling requests only
maxHttpBufferSize: 16384, // default is 1e6
perMessageDeflate: false,
allowRequest: (req, callback) => { // runs for polling requests and for the upgrade
const origin = req.headers.origin;
callback(null, origin === undefined || allowed.has(origin));
},
});
io.on("connection", (socket) => socket.on("ping-me", (ack) => ack?.("pong")));
httpServer.listen(Number(process.env.PORT ?? 18201), "127.0.0.1", () => console.log("listening"));
Source: Socket.IO docs: Server options
Testing it with the Node.js client, which can set an Origin header, and with an event larger than
maxHttpBufferSize:
import { io } from "socket.io-client";
const url = "http://127.0.0.1:18201";
const attempt = (origin) => new Promise((resolve) => {
const s = io(url, { transports: ["websocket"], extraHeaders: { Origin: origin }, reconnection: false });
s.on("connect", () => { s.disconnect(); resolve("connected"); });
s.on("connect_error", (e) => { s.disconnect(); resolve("refused: " + e.message); });
});
console.log("allowed origin ->", await attempt("https://chat.example.test"));
console.log("evil origin ->", await attempt("https://evil.example"));
const big = io(url, { transports: ["websocket"], extraHeaders: { Origin: "https://chat.example.test" }, reconnection: false });
big.on("connect", () => big.emit("ping-me", "x".repeat(20000)));
big.on("disconnect", (reason) => { console.log("20000-char event -> disconnect:", reason); process.exit(0); });
Source: Socket.IO docs: Server options
allowed origin -> connected
evil origin -> refused: websocket error
20000-char event -> disconnect: transport close
An oversized event does not produce an error in the application: the transport is closed.
Django Channels
OriginValidator takes an explicit list, and AllowedHostsOriginValidator accepts the origins whose host is in Django’s
ALLOWED_HOSTS. Both refuse a connection that has no Origin at all, so a native client needs a custom subclass, as
the Chatterbox page does.
import asyncio
import django
from django.conf import settings
settings.configure(ALLOWED_HOSTS=["chat.example.test"], DEBUG=False)
django.setup()
from channels.generic.websocket import AsyncWebsocketConsumer
from channels.security.websocket import AllowedHostsOriginValidator, OriginValidator
from channels.testing import WebsocketCommunicator
class Echo(AsyncWebsocketConsumer):
async def connect(self):
await self.accept()
stock = OriginValidator(Echo.as_asgi(), ["https://chat.example.test"])
hosts = AllowedHostsOriginValidator(Echo.as_asgi())
async def try_origin(app, origin):
headers = [(b"origin", origin.encode())] if origin else []
comm = WebsocketCommunicator(app, "/ws", headers=headers)
connected, code = await comm.connect()
await comm.disconnect() if connected else None
return "accepted" if connected else f"refused ({code})"
async def main():
for origin in ("https://chat.example.test", "https://evil.example", None):
print(f"OriginValidator {origin!s:28} -> {await try_origin(stock, origin)}")
for origin in ("https://chat.example.test", "https://evil.example"):
print(f"AllowedHostsValidator {origin!s:28} -> {await try_origin(hosts, origin)}")
asyncio.run(main())
Source: Channels docs: Security
OriginValidator https://chat.example.test -> accepted
OriginValidator https://evil.example -> refused (1000)
OriginValidator None -> refused (1000)
AllowedHostsValidator https://chat.example.test -> accepted
AllowedHostsValidator https://evil.example -> refused (1000)
Frameworks that configure it declaratively
SignalR, Spring WebFlux, Reverb and python-socketio need configuration rather than code. The fragments below are the shape of that configuration; the linked pages run them.
// SignalR: CORS covers /chathub/negotiate, AllowedOrigins covers the WebSocket handshake
app.UseWebSockets(new WebSocketOptions { AllowedOrigins = { "https://chat.example.test" } });
// config/reverb.php, apps.apps[0]
'allowed_origins' => ['chat.example.test'], // the default is ['*']
'max_message_size' => 10_000, // bytes, enforced on every client message
Source: Laravel docs: Reverb
Transport: wss only
-
Serve the page over HTTPS and the socket over
wss://. A page loaded over HTTPS cannot openws://at all (mixed content), but a page loaded over plain HTTP can, and then every frame, cookie and token is readable and modifiable on the path. Redirect HTTP to HTTPS and sendStrict-Transport-Security. -
Terminate TLS at the proxy or load balancer, but keep the hop to the application on a trusted network or use TLS there too. Frames inside a cluster (Redis backplane, XMPP s2s) are plaintext unless you protect them: use
rediss://, authentication and a private network. -
Pin protocol versions to TLS 1.2 and 1.3, and keep certificates renewing automatically. For the TURN server, offer
turns:on port 443 for networks that block everything else (see ICE, STUN and TURN). -
Do not log or put secrets in the URL: query strings end up in proxy logs, browser history and the
Referer. A one-time ticket that expires in seconds is the accepted compromise when a header cannot be set. -
Support only RFC 6455 version 13, and ignore old draft hybi protocols.
Input validation and output encoding
Every frame is untrusted input, whatever authentication preceded it.
-
Parse with
JSON.parse(or the stack’s equivalent) in atryblock, reject non-objects, checkv,type,id,roomand the payload against a schema, and close with 4400 on a malformed envelope. Check string lengths (a Chatterboxtextis 1 to 4000 characters), the number of attachments (0 to 5), and room names against the allowed pattern. -
Validate binary content by length and by the first bytes (magic numbers), never by a client-supplied MIME type.
-
Check that a client only acts on its own behalf: an
editof somebody else’s message, ajoinof a private room or a forgedfromfield must fail withforbidden. The server setsfrom,tsandseq; it ignores the client’s values. -
Make operations idempotent by envelope id so that a replayed frame does nothing new.
-
Encode on output. A chat shows other people’s text, so stored XSS is the most likely real attack. Insert text with
textContentor a framework binding, never withinnerHTML, and render Markdown or links through an allow-list sanitiser. Validate that attachment URLs arehttpsand notjavascript:ordata:. Do the same for user names, room names and presence status. AContent-Security-Policythat forbids inline scripts limits the damage when an encoding slips. -
Never build SQL, shell commands, file paths or template strings from a frame.
Size, rate and connection limits
Defaults are generous because libraries cannot know the application. Set explicit values from the protocol.
| Control | Setting | On violation |
|---|---|---|
Frame size |
|
Close code 1009 (Message Too Big), or the Chatterbox 4413; Socket.IO and SignalR close the transport without a code you can act on. |
Message rate |
A token bucket per connection and per user, as in the example. Chatterbox: 20 per second, burst 40. Reverb: |
|
Connections per address |
A counter, or nginx |
HTTP 429 on the handshake. |
Connections per user |
A registry keyed by user id; evict the oldest on the sixth. |
Close 4403 or 4429 for the evicted one. |
Handshake rate |
nginx |
HTTP 429 or 503. |
Idle time |
Heartbeats plus a server timeout (uWebSockets.js |
Close 4408. |
Time to first frame |
Close the socket if |
Close 4408. |
Limit by the authenticated user where you can and by address where you cannot: addresses are shared by offices and
mobile carriers behind CGNAT, so a per-address cap of 4 is a lab value and a production one is in the tens, with a
higher allowance for known corporate ranges. Fragmented messages need the limit on the reassembled size; libraries with a
maxPayload apply it to the total, but a hand-written parser must too. Control frames (ping, pong, close) are small but
can be flooded, so count them in the rate limit as well.
Slow consumers and resource exhaustion
A client that stops reading but keeps the TCP connection open makes the server queue every broadcast for it. Without a bound, one slow reader grows memory until the process is killed, and that is a classic DoS.
-
Watch the per-socket queue (
ws.bufferedAmountinws, thegetBufferedAmount()of uWebSockets.js, thedrainevent, ReactoronBackpressure, a boundedChannelin .NET) and decide a policy: drop low-value messages such astypingandpresence, close the socket with 1013 (Try Again Later) or 4503, or switch the user to a snapshot-only mode. The example above closes at 1 MiB. -
Prefer small bounded queues and shed load at the edge over unbounded buffers in the application.
-
Cap what one message may trigger: a
historyrequest is limited to 100 messages; ajoinof a room with 100,000 members should send a count, not the roster. -
Bound the memory per connection (compression contexts, buffers, session state). File descriptors, memory per socket and kernel limits are on the scaling page.
-
Handle the unexpected: unhandled promise rejections or an exception in one handler must close one socket, not the process. Run behind a supervisor and a memory limit (see Docker security and Kubernetes for the settings).
Reverse proxies
A proxy is a place to enforce limits that cost nothing in the application and to hide the real origin servers. This
nginx fragment applies the Origin allow-list, the per-address connection cap, the handshake rate limit and the headers a
WebSocket needs; nginx accepted it with nginx -t in the 1.28 image.
events {}
http {
map $http_upgrade $connection_upgrade { default upgrade; "" close; }
limit_conn_zone $binary_remote_addr zone=ws_conn:10m;
limit_req_zone $binary_remote_addr zone=ws_handshake:10m rate=5r/s;
# Reject an unexpected Origin before the request reaches the application.
map $http_origin $origin_ok {
default 0;
"" 1; # non-browser clients; they authenticate in the first frame
"https://chat.example.test" 1;
}
server {
listen 80;
location /ws {
if ($origin_ok = 0) { return 403; }
limit_conn ws_conn 20; # concurrent sockets per client address
limit_req zone=ws_handshake burst=10 nodelay; # new handshakes per second
client_max_body_size 64k;
proxy_pass http://127.0.0.1:18201;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header X-Forwarded-For $remote_addr; # overwrite, do not append, a client-supplied value
proxy_read_timeout 60s; # above 2 x the 20 s Chatterbox heartbeat
}
}
}
Source: nginx docs: WebSocket proxying
-
Without
proxy_http_version 1.1and the twoUpgradeandConnectionheaders, nginx answers the handshake as an ordinary request and the client sees a failed upgrade. -
proxy_read_timeoutdefaults to 60 s: a socket silent for that long is closed, so heartbeats must be more frequent. -
Set
X-Forwarded-Forfrom$remote_addr(overwriting, as above) at the first trusted proxy and read it in the application only when the request came from that proxy. A client can send its ownX-Forwarded-For, and trusting it lets an attacker evade a per-address limit or forge an audit log. -
Do not expose the application port, the Redis backplane, the admin UI, the coturn Prometheus port or Pulse to the internet. Cloud load balancers and ingress controllers have equivalents; see Proxies, Load Balancers and Deployment, Ingress, Network Policies and Docker security.
The compression oracle
permessage-deflate (RFC 7692) compresses each message. When a secret and attacker-controlled text are compressed in the
same context, the compressed length depends on whether the attacker’s text repeats part of the secret: this is the idea
behind CRIME and BREACH. With context takeover (the default), the compressor also keeps a sliding window across
messages, so the secret in one frame and the guess in the next interact. The example makes the leak visible without any
network.
// oracle.mjs - length of a compressed frame leaks whether attacker text repeats a secret
import { deflateRawSync } from "node:zlib";
const secret = "csrf=Zq7xP2mK9wLd"; // value the server puts in every frame
const frame = (guess) => JSON.stringify({ v: 1, type: "message", payload: { text: guess, note: secret } });
const size = (guess) => deflateRawSync(Buffer.from(frame(guess))).length;
for (const guess of ["csrf=Aa1bB2cC3dDe", "csrf=Zq7xP2mK9wLd"]) {
console.log(guess.padEnd(20), "-> compressed", size(guess), "bytes");
}
csrf=Aa1bB2cC3dDe -> compressed 84 bytes
csrf=Zq7xP2mK9wLd -> compressed 73 bytes
The correct guess is 11 bytes shorter. An attacker who can inject text and observe frame sizes (on the wire, or from a position on the network) repeats this one character at a time. What to do:
-
Keep
perMessageDeflatedisabled unless measurements show you need it; the OWASP cheat sheet gives the same advice. Chat text is small and compresses poorly anyway, and each compressed socket costs memory for its zlib windows (about 300 KB per direction at the defaults, less withserver_no_context_takeoverand a small window). -
If you enable it, use
server_no_context_takeoverandclient_no_context_takeover, limit the window bits, and never put secrets (tokens, CSRF values, session ids) in frames that also carry user-supplied text. -
Compression also enables decompression bombs: a tiny frame that expands to a huge one. Keep
maxPayloadas a limit on the decompressed size where the library supports it. -
Binary game snapshots rarely benefit from it. Use a compact encoding instead (see Message Design and Serialization).
WebRTC
Address exposure and permissions
-
Every ICE candidate describes a network address. The default
iceTransportPolicyvalueallgathers host, server-reflexive and relay candidates, so each participant of a mesh call learns the public address of the others. Browsers hide local addresses behind mDNS names, which does not hide the public one. Where that matters (strangers in a room, whistleblower tools, privacy-sensitive products), seticeTransportPolicytorelayso that only the TURN server’s address is revealed, at the price of bandwidth through your relay. -
Say so in the privacy notice. Signalling is untrusted input too: validate
offer,answerandcandidatemessages by size and structure, and make sure a peer can only signal to peers in its own room. -
getUserMediaandgetDisplayMedianeed a secure context, and screen capture needs a user gesture. Embedded frames needPermissions-Policy: camera=(self), microphone=(self)(anddisplay-capture) or anallowattribute, and should not get them by default. Show a visible indicator and a mute control; stop tracks when the call ends so the camera light goes off. See Media Capture and Devices and Screen Sharing. -
Media is always encrypted with DTLS-SRTP, but through an SFU it is encrypted hop by hop: the server sees it. For confidentiality against the server use encoded transforms with SFrame, described on End-to-End Encryption.
-
Rooms need access control. A link that is the only secret is a bearer token: use unguessable room ids, expiring invitations and a host who can lock the room and remove participants. LiveKit, Janus and mediasoup take that decision through signed join tokens or application code; see LiveKit and mediasoup.
-
Data channels can carry arbitrary data from a peer: apply the same size, rate and validation rules as to a WebSocket (WebRTC Data Channels).
TURN abuse and SSRF
A TURN server forwards packets to any address that a client names with CreatePermission or ChannelBind. An attacker
who obtains one credential from your credentials endpoint can therefore use the relay as a proxy to reach hosts that are
only visible from the server’s network: databases, an admin panel, the cloud metadata address 169.254.169.254, other
containers. This is server-side request forgery (SSRF) through TURN. The control is denied-peer-ip for every private
and special range, with allowed-peer-ip for the few internal addresses the relay must reach (for example an SFU).
The configuration below denies the ranges, exempts a single peer and limits quotas. It was run in Docker with coturn
4.18.0 and turnutils_uclient, requesting a relay to several peers:
listening-port=3478
min-port=18250
max-port=18299
use-auth-secret
static-auth-secret=example-shared-secret-change-me
realm=huddle.example.test
fingerprint
no-tls
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=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.168.0.0-192.168.255.255
allowed-peer-ip=172.30.0.50
user-quota=4
total-quota=100
verbose
Source: coturn example turnserver.conf
The test client authenticates with a REST-style credential (-W is the shared secret) and asks the relay to talk to
different peers. The address 172.30.0.50 is on the allow list; the others are denied:
docker network create --subnet 172.30.0.0/24 security-net
docker run -d --name turn --network security-net --ip 172.30.0.10 -p 18200:3478/udp \
-v "$PWD/turnserver.conf:/etc/coturn/turnserver.conf:ro" coturn/coturn:4.18.0 -c /etc/coturn/turnserver.conf
docker run -d --name peer --network security-net --ip 172.30.0.50 --entrypoint sleep coturn/coturn:4.18.0 600
for peer in 172.30.0.50 172.30.0.99 169.254.169.254 10.0.0.5 127.0.0.1; do
echo "== peer $peer"
docker exec peer turnutils_uclient -W example-shared-secret-change-me -u user1 -n 1 -c -e $peer 172.30.0.10 \
| grep -i error
done
Source: coturn wiki: turnutils_uclient
== peer 172.30.0.50
== peer 172.30.0.99
... INFO channel bind: error 403 ()
== peer 169.254.169.254
... INFO channel bind: error 403 ()
== peer 10.0.0.5
... INFO channel bind: error 403 ()
== peer 127.0.0.1
... INFO channel bind: error 403 ()
(Use a different user name per run in a small lab: user-quota counts allocations per user.) The server log names the
reason: error 403: Forbidden IP. The allowed peer produces no error and its permission is created, and a request with
a wrong shared secret fails earlier with error 401: Unauthorized.
Beyond denied-peer-ip:
-
Issue ephemeral credentials (
use-auth-secret; the username carries an expiry timestamp) from an authenticated endpoint, rate-limit that endpoint and bind the user id into the username so that logs show who used the relay. -
Never run
no-auth, never ship a static password to the browser, and leave the coturn command line interface off (the default in 4.18) or protect it with a strongcli-password. Open relays are found by scanners within hours. -
Set
user-quota,total-quota,max-bpsandbps-capacityso one account cannot consume the server, andno-multicast-peers. Do not expose the Prometheus port. -
Place the TURN server in a network zone with no sensitive neighbours, so that the deny list is a second barrier and not the only one. Remember that
denied-peer-ipmust include the cloud private ranges of your provider. -
A TURN server on UDP can be abused for reflection and amplification when it answers unauthenticated requests; keep
fingerprint, use authentication and keep software current.
The configuration and the rest of the setup are on ICE, STUN and TURN, and capacity and placement on Scaling WebRTC and TURN.
XMPP
-
Registration spam. Open in-band registration is the biggest operational risk of a public server. Keep it closed (
allow_registration = falsein Prosody,access: registerin ejabberd) and create accounts from your application, or put invitations, e-mail verification or a CAPTCHA in front, with a rate limit per address. See XMPP Servers. -
Federation. Server-to-server connections let your users talk to the world and the world talk to them. Require TLS and certificate validation for s2s, restrict federation to an allow-list for a private deployment, enforce per-sender rate limits, and block known abusive domains. Disable federation altogether when the chat is internal.
-
MUC and file upload. Put limits on room creation, on room size, on history requests and on HTTP upload quotas; moderate with roles and bans (see History, Offline Delivery and Moderation).
-
Authentication. Prefer SCRAM over PLAIN, always over TLS, and rate-limit failed logins. A browser client that connects through the WebSocket endpoint is subject to the same
Originhandling andwssrequirements as the rest of this page (XMPP in the Browser). -
Encryption. TLS protects the hops, and the server reads the stanzas. OMEMO gives end-to-end encryption between clients (OMEMO Encryption).
Logging, monitoring and testing
-
Log connects, disconnects, close codes, rejected origins, rate-limit hits and authentication failures with a user id and a request id, but never tokens, ticket values or message bodies. Count them as metrics so that a sudden change in 1009, 4429 or 403 rates raises an alert (see Observability).
-
Test the guards. The probes on this page are the template: a request with a foreign
Origin, an oversized frame, a burst, a missinghello, a replayed ticket, a bad JSON frame. Automate them in the integration tests of each stack (Testing Real-Time Apps) and exercise the system with the tools in Load Testing and Capacity Planning. -
Keep libraries current. WebSocket servers have had denial-of-service advisories (for instance in header handling and frame parsing), so subscribe to the advisories of
ws, Socket.IO, Daphne, Netty, the SignalR runtime and coturn. -
Review who may call the credentials, ticket and token endpoints: they are the front door of the real-time system. See OAuth for browser-based apps for the token side and CORS for the HTTP half of the Origin story.
Security checklist
| Area | Check |
|---|---|
Transport |
|
Origin |
Explicit allow-list on every handshake, from configuration; no wildcard with cookies; exact string comparison; |
Authentication |
Verified in the first frame or by a ticket; token expiry handled on long-lived sockets; authorization per message and per room. |
Input |
Schema validation of every envelope; length limits; server-set |
Output |
|
Limits |
|
Resources |
Bounded send queues and a slow-consumer policy; no unhandled |
Compression |
|
Proxy |
Upgrade headers; read timeout above the heartbeat; trusted |
WebRTC |
Ephemeral TURN credentials; |
TURN |
|
XMPP |
Closed or controlled registration; s2s TLS and allow-list; rate limits; upload and MUC quotas. |
Operations |
Logs and metrics of rejections; guard tests in CI; dependencies updated. |
Common errors and pitfalls
-
Relying on CORS for WebSockets. CORS never blocks a WebSocket handshake. Check
Origin. -
Allowing any origin while using cookies. A wildcard in
allowed_origins(the Reverb default) or incors_allowed_originsis development settings. -
Substring or suffix matching.
endsWith("chat.example.test")acceptsevilchat.example.test. Compare exact strings. -
Treating Origin as authentication. It is a header that non-browser clients set freely.
-
No error listener on the socket. In
wsan oversized frame emitserror; unhandled, it crashes the process. -
Default sizes. 100 MiB (
ws) or 1 MB (Socket.IO) per frame, multiplied by thousands of sockets, is a memory attack waiting to happen. -
Per-address limits behind a proxy. All users share the proxy address until you read a trusted
X-Forwarded-For. -
Tokens in the query string kept in logs. Use one-time tickets with a seconds-long lifetime and scrub URLs in logs.
-
Compression on by default plus secrets. See the oracle above.
-
TURN without a deny list. The relay becomes a door into your private network.
-
A
denied-peer-iplist that omits the provider’s ranges. The metadata address 169.254.169.254 is link-local, but internal services often live in 172.16.0.0/12 or the provider’s own blocks.
Version and specification notes
-
RFC 6455 section 10 holds the security considerations of WebSocket, including the
Origincheck and the masking of client frames, which protects intermediaries from cache poisoning. RFC 7692 definespermessage-deflate. RFC 8656 defines TURN, including permissions and channel binding; the deny-list is an implementation control. -
RFC 8825 and RFC 8826 describe the WebRTC overview and its security considerations; RFC 8827 covers the security architecture, including the permission model and consent freshness.
-
The "TURN REST API" credential scheme is an Internet-Draft that coturn and eturnal implement; the settings differ slightly between them (coturn
use-auth-secret, eturnalsecret). -
WebSockethas no API to set request headers in browsers; anOriginis added by the browser and cannot be removed by page script.