Security & TLS Configuration #

A WebSocket that opens over ws:// instead of wss:// is a long-lived, bidirectional plaintext tunnel running through every proxy, captive portal, and corporate middlebox between the browser and your server. The failure is rarely loud. A user on hotel Wi-Fi connects, an intercepting proxy strips or rewrites frames, and your state-sync stream silently delivers tampered payloads — no certificate warning, no console error, because the page itself loaded over HTTPS while the socket quietly fell back to cleartext. The second failure mode is just as quiet: you ship a perfectly encrypted wss:// endpoint with no Origin check, and any third-party site can open an authenticated socket against it on behalf of a logged-in user, because the WebSocket upgrade skips the CORS preflight that would normally block a cross-site fetch.

This guide covers the transport and handshake layer for production WebSockets: enforcing TLS 1.3, terminating TLS at a reverse proxy, validating the Origin header on the upgrade, and rotating certificates without tearing down active connections. It is the security counterpart to protocol selection — once you have chosen WebSockets, this is what makes the channel safe to expose.

Prerequisites #

Before hardening transport you should already have the basics in place from the rest of Real-Time Protocol Selection & Architecture:

Where security lives in the request path #

The diagram below shows the two boundaries you control. TLS is terminated at the edge proxy, so frames travel encrypted from the browser to the proxy and as plaintext (or re-encrypted) on a trusted internal hop. The application server then runs the cheap-but-mandatory Origin check before it accepts the upgrade.

WebSocket TLS termination and origin check path Browser connects over wss to an edge proxy that terminates TLS, which forwards the upgrade to an app server that validates the Origin header before accepting. Browser wss:// + Origin Edge proxy TLS 1.3 termination App server Origin allowlist encrypted trusted hop X-Forwarded-Proto: https 403 bad Origin

Core implementation #

The server below terminates wss:// directly (useful for development, single-node deploys, or when you deliberately keep TLS end-to-end). It enforces TLS 1.3, restricts the cipher list, validates Origin against an allowlist on the upgrade, and hot-reloads the certificate so renewals do not drop live sockets.

import { createServer, type Server as HttpsServer } from "node:https";
import { readFileSync, watch } from "node:fs";
import { WebSocketServer } from "ws";
import type { IncomingMessage } from "node:http";

const CERT_PATH = "/etc/letsencrypt/live/example.com/fullchain.pem";
const KEY_PATH = "/etc/letsencrypt/live/example.com/privkey.pem";

// Only origins on this list may complete the upgrade. Exact-match, no wildcards.
const ALLOWED_ORIGINS = new Set([
"https://app.example.com",
"https://dashboard.example.com",
]);

function loadTlsOptions() {
return {
cert: readFileSync(CERT_PATH),
key: readFileSync(KEY_PATH),
minVersion: "TLSv1.3" as const, // refuse TLS 1.2 and below outright
// TLS 1.3 suites only; legacy CBC/RC4 suites are not selectable here
ciphers: "TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256",
honorCipherOrder: true,
};
}

const server: HttpsServer = createServer(loadTlsOptions());

// Hot-reload the cert in place on renewal. setSecureContext swaps the keypair
// for NEW handshakes without closing any currently-open socket.
watch(CERT_PATH, () => {
try {
server.setSecureContext(loadTlsOptions());
console.info("tls.cert.reloaded");
} catch (err) {
console.error("tls.cert.reload_failed", err); // keep serving the old cert
}
});

// noServer lets us validate the upgrade BEFORE the WebSocket is constructed.
const wss = new WebSocketServer({ noServer: true });

server.on("upgrade", (req: IncomingMessage, socket, head) => {
const origin = req.headers.origin ?? "";

// The browser sends Origin on the upgrade GET, but no CORS preflight runs,
// so this is the only gate that stops cross-site socket hijacking.
if (!ALLOWED_ORIGINS.has(origin)) {
socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
socket.destroy(); // refuse before allocating a WebSocket
return;
}

wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit("connection", ws, req);
});
});

wss.on("connection", (ws, req) => {
// Transport is trusted at this point; identity (JWT/session) is layered on top.
ws.on("message", (data) => {
// ...route and broadcast state updates...
});
});

// tlsClientError fires when a client fails the handshake (e.g. offers TLS 1.2).
server.on("tlsClientError", (err) => {
console.warn("tls.client_error", (err as NodeJS.ErrnoException).code);
});

server.listen(443);

When TLS is terminated at the edge instead (the common production layout), the application server listens on plain HTTP on a private interface and trusts X-Forwarded-Proto. The nginx side:

upstream ws_backend {
ip_hash; # pin a client to one backend for the socket's life
server 10.0.0.1:8080;
server 10.0.0.2:8080;
}

server {
listen 443 ssl;
http2 on;

ssl_certificate /etc/nginx/ssl/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
ssl_protocols TLSv1.3; # drop TLS 1.2 once clients allow it
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;

add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;

location /ws {
proxy_pass http://ws_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; # preserve the upgrade token
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme; # backend reads this, not the socket
proxy_read_timeout 3600s; # longer than your heartbeat interval
proxy_send_timeout 3600s;
proxy_buffering off; # stream frames immediately
}
}

Configuration reference #

Parameter Type Default Production value Notes
minVersion (Node TLS) string TLSv1.2 TLSv1.3 Rejects downgrade attempts; verify no legacy clients first.
ciphers string OpenSSL default TLS 1.3 AEAD suites only TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256.
honorCipherOrder boolean false true Server picks the cipher, not the client.
ssl_protocols (nginx) string TLSv1.2 TLSv1.3 TLSv1.3 Drop 1.2 only after confirming client coverage.
Strict-Transport-Security header absent max-age=63072000; includeSubDomains; preload Forces https/wss on subsequent loads.
proxy_read_timeout duration 60s 3600s Must exceed your max heartbeat gap or idle sockets get killed.
proxy_buffering flag on off Buffering delays frame delivery and breaks streaming.
ALLOWED_ORIGINS set exact origin strings No wildcards; one entry per trusted front-end host.
Certificate reload mechanism manual restart setSecureContext on file change Swaps keypair for new handshakes, keeps live sockets.

Edge cases & gotchas #

  • Mixed-content fallback to ws://. A page served over HTTPS cannot open a ws:// socket — browsers block it as mixed content — but code that builds the URL from location.protocol and gets it wrong fails silently in some embedded webviews. Always derive the scheme: location.protocol === "https:" ? "wss:" : "ws:".
  • Origin is absent for non-browser clients. Native mobile apps, server-to-server clients, and curl send no Origin header. An allowlist that rejects empty origins will lock them out; decide deliberately whether those clients authenticate by token instead, and document the exception rather than loosening the browser path.
  • Terminating TLS twice. If both the proxy and the app server terminate TLS, the app server sees the proxy’s IP as the peer and X-Forwarded-Proto becomes the only signal that the original hop was encrypted. Trust that header only from known proxy IPs, or an attacker can spoof https over a plain connection.
  • Certificate reload races. watch can fire while ACME is mid-write, handing you a half-written PEM. Wrap setSecureContext in try/catch (as above) so a transient parse error keeps the old, valid context serving instead of crashing the process.

Verification #

Confirm the transport is actually doing what the config claims:

# Negotiated TLS version and cipher for the wss endpoint
openssl s_client -connect app.example.com:443 -alpn http/1.1 </dev/null 2>/dev/null \
| grep -E "Protocol|Cipher"

# Assert TLS 1.2 is refused (should fail to connect)
openssl s_client -connect app.example.com:443 -tls1_2 </dev/null

# A cross-origin upgrade must be rejected with 403
curl -i -N -H "Origin: https://evil.example" \
-H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" -H "Sec-WebSocket-Version: 13" \
https://app.example.com/ws

# Count established sockets on the app server during a deploy/cert reload
ss -tnp state established '( sport = :8080 )' | wc -l

In browser DevTools, open the Network tab, filter to WS, and confirm the request URL starts with wss:// and the response is 101 Switching Protocols. Watch the established-socket count across a certificate reload — it should hold steady, proving setSecureContext did not drop connections.

Guides in this area #

FAQ #

Why do I need an Origin check if the socket is already over TLS? #

TLS protects the channel from eavesdropping and tampering; it says nothing about which site opened the connection. Because the WebSocket upgrade skips the CORS preflight, any origin can open an authenticated wss:// socket against your server using the user’s cookies. The Origin allowlist is the gate that a cross-site fetch would normally get from CORS.

Does TLS termination at a load balancer break WebSockets? #

No, as long as the load balancer is configured to forward the Upgrade and Connection headers and uses sticky routing for the socket’s lifetime. The backend then trusts X-Forwarded-Proto: https instead of inspecting the socket. This is the standard production layout behind AWS ALB, nginx, or HAProxy.

Can I rotate a certificate without dropping active connections? #

Yes. server.setSecureContext() swaps the keypair used for new TLS handshakes while leaving every already-established socket untouched, since those sessions are already keyed. Watch the cert file and call it on change. Restarting the process, by contrast, severs every live connection.

Should I enforce TLS 1.3 only, or keep TLS 1.2? #

Enforce TLS 1.3 if your client population supports it — it removes a whole class of downgrade and CBC-padding attacks. Audit your clients first: very old embedded webviews and some corporate proxies still cap at TLS 1.2. Drop 1.2 only after the handshake-error logs confirm no real traffic depends on it.

How is this different from authenticating the WebSocket? #

Transport hardening proves the channel is encrypted and the connecting site is trusted. It does not prove who the user is. Identity comes from a token or session validated on the upgrade, covered in WebSocket Authentication & Authorization. Treat them as two distinct layers: one secures the pipe, the other names the caller.

Back to Real-Time Protocol Selection & Architecture