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

Origin allow-list, authentication in the first frame, SameSite cookies

Traffic in transit

Eavesdropping or tampering on ws://, or on a proxy hop

wss:// only, HSTS, TLS to the application when the network is untrusted

Other users' browsers

Stored or reflected XSS delivered through chat text, usernames or room names

Output encoding, no innerHTML, a Content-Security-Policy

Server memory and CPU

Huge frames, floods, thousands of idle sockets, slow readers, compression bombs

maxPayload, rate limits, connection caps, backpressure, timeouts

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

iceTransportPolicy, Permissions-Policy, user-gesture capture

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

denied-peer-ip, ephemeral credentials, quotas

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 Origin on 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 any Origin or none. The check says nothing about them, so it never replaces authentication. Chatterbox accepts a handshake with no Origin and makes such clients prove themselves with a one-time ticket in the hello frame.

  • Origin is 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"));

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);

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 ws library also emits an error event on the server-side socket for that frame; without an error listener 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. maxPayload is 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_limited errors (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 trusted X-Forwarded-For hop 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

ws (Node.js)

Check req.headers.origin in the HTTP server’s upgrade event, as above.

verifyClient is deprecated in favour of this.

Socket.IO (Node.js)

allowRequest(req, callback), which runs for polling requests and for the WebSocket upgrade. See below.

The cors option only adds response headers for polling and is not access control for WebSockets. Socket.IO

python-socketio

cors_allowed_origins (default: same origin only). python-socketio

'*' disables the check. A request with no Origin is not restricted.

Django Channels

OriginValidator or AllowedHostsOriginValidator around the router. See below.

Django Channels

SignalR

CORS for the negotiate request plus WebSocketOptions.AllowedOrigins for the socket.

SignalR

Spring WebFlux

A CorsConfigurationSource on the handler mapping, answered with 403.

Spring WebFlux WebSockets

uWebSockets.js

Check the origin request header in the upgrade handler and answer 403.

uWebSockets.js

Laravel Reverb

allowed_origins in the app definition (default ['*']); REVERB_ALLOWED_ORIGINS.

Change the default. Laravel Reverb

Swoole

Read the origin entry of the request headers in the handshake callback or before accepting in open.

Swoole

Atmosphere

An interceptor or a servlet filter on the upgrade request.

Atmosphere

XMPP servers

Server-specific Origin/CORS handling of the WebSocket and BOSH endpoints.

XMPP Servers

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"));

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); });
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())
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

Transport: wss only

  • Serve the page over HTTPS and the socket over wss://. A page loaded over HTTPS cannot open ws:// 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 send Strict-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 a try block, reject non-objects, check v, type, id, room and the payload against a schema, and close with 4400 on a malformed envelope. Check string lengths (a Chatterbox text is 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 edit of somebody else’s message, a join of a private room or a forged from field must fail with forbidden. The server sets from, ts and seq; 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 textContent or a framework binding, never with innerHTML, and render Markdown or links through an allow-list sanitiser. Validate that attachment URLs are https and not javascript: or data:. Do the same for user names, room names and presence status. A Content-Security-Policy that 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

ws: maxPayload. Socket.IO: maxHttpBufferSize (default 1 MB). uWebSockets.js: maxPayloadLength (16 KB). SignalR: MaximumReceiveMessageSize (32 KB). Reverb: max_message_size. Atmosphere: WEBSOCKET_MAXTEXTSIZE. nginx: client_max_body_size for the handshake only.

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: rate_limiting for client events.

rate_limited error with retryable: true, then close 4429.

Connections per address

A counter, or nginx limit_conn. Reverb: max_connections.

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 limit_req on the upgrade location.

HTTP 429 or 503.

Idle time

Heartbeats plus a server timeout (uWebSockets.js idleTimeout 120 s, Chatterbox 2 x heartbeatMs).

Close 4408.

Time to first frame

Close the socket if hello has not arrived within 5 s.

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.bufferedAmount in ws, the getBufferedAmount() of uWebSockets.js, the drain event, Reactor onBackpressure, a bounded Channel in .NET) and decide a policy: drop low-value messages such as typing and presence, 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 history request is limited to 100 messages; a join of 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
    }
  }
}
  • Without proxy_http_version 1.1 and the two Upgrade and Connection headers, nginx answers the handshake as an ordinary request and the client sees a failed upgrade.

  • proxy_read_timeout defaults to 60 s: a socket silent for that long is closed, so heartbeats must be more frequent.

  • Set X-Forwarded-For from $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 own X-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 perMessageDeflate disabled 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 with server_no_context_takeover and a small window).

  • If you enable it, use server_no_context_takeover and client_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 maxPayload as 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 iceTransportPolicy value all gathers 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), set iceTransportPolicy to relay so 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, answer and candidate messages by size and structure, and make sure a peer can only signal to peers in its own room.

  • getUserMedia and getDisplayMedia need a secure context, and screen capture needs a user gesture. Embedded frames need Permissions-Policy: camera=(self), microphone=(self) (and display-capture) or an allow attribute, 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

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
== 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 strong cli-password. Open relays are found by scanners within hours.

  • Set user-quota, total-quota, max-bps and bps-capacity so one account cannot consume the server, and no-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-ip must 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 = false in Prosody, access: register in 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 Origin handling and wss requirements 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 missing hello, 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

wss:// only; HSTS; TLS 1.2 or newer; no secrets in URLs apart from a one-time ticket; Redis and inter-server links protected.

Origin

Explicit allow-list on every handshake, from configuration; no wildcard with cookies; exact string comparison; SameSite on the session cookie.

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 from, ts and seq; idempotent ids.

Output

textContent or sanitised rendering; URL scheme allow-list for attachments; CSP.

Limits

maxPayload (16 KiB here); rate limit per socket and per user; connections per address and per user; hello timeout; idle timeout.

Resources

Bounded send queues and a slow-consumer policy; no unhandled error events; memory limit and supervisor.

Compression

permessage-deflate off, or no-context-takeover and no secrets in frames with user text.

Proxy

Upgrade headers; read timeout above the heartbeat; trusted X-Forwarded-For only; internal ports not exposed.

WebRTC

Ephemeral TURN credentials; iceTransportPolicy decision documented; permissions policy for frames; room access control; encrypted media understood as hop-by-hop through an SFU.

TURN

denied-peer-ip for private and special ranges; allowed-peer-ip minimal; quotas; no no-auth; no open CLI or metrics.

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 in cors_allowed_origins is development settings.

  • Substring or suffix matching. endsWith("chat.example.test") accepts evilchat.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 ws an oversized frame emits error; 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-ip list 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 Origin check and the masking of client frames, which protects intermediaries from cache poisoning. RFC 7692 defines permessage-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, eturnal secret).

  • WebSocket has no API to set request headers in browsers; an Origin is added by the browser and cannot be removed by page script.