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 #

Terminating TLS in front of the application #

Almost every production WebSocket deployment terminates TLS at a proxy and forwards plaintext to the application on a private interface. That is the right shape — it keeps expensive cryptography off the Node event loop and gives you certificate renewal without touching the app — but it introduces a set of failure modes that are entirely about the proxy’s defaults.

Four settings decide whether it works, and all four default to values written for request/response traffic. The proxy must speak HTTP/1.1 upstream, because the upgrade mechanism does not exist in 1.0. It must re-add the Connection header, which it correctly strips by default as a hop-by-hop field. It must raise its read and send timeouts above your heartbeat interval, or every idle connection dies at sixty seconds. And it must forward X-Forwarded-Proto, because after termination the application has no other way to know the client used TLS — which matters for origin checks, cookie Secure decisions and any redirect it might issue.

Certificate renewal deserves its own attention. Reloading the proxy rather than restarting it lets old worker processes keep serving established tunnels while new ones pick up the new certificate, so live connections survive. A restart severs everything at once — a self-inflicted reconnect storm every sixty days, at whatever hour your renewal cron runs. Set a shutdown timeout so old workers cannot accumulate indefinitely across renewals, since a WebSocket may not close for hours.

Two settings are worth a deliberate decision rather than a default. Session tickets trade forward secrecy for cheaper resumption; disabling them costs a little handshake latency on reconnect and is the usual choice for anything sensitive. And OCSP stapling removes a client-side round trip to the certificate authority during the handshake, which is pure benefit and frequently left off.

Finally, remember that everything upstream of the termination point is plaintext. It must stay on loopback or a private network, and if your architecture routes that traffic across a shared network, the encryption you carefully configured has a gap exactly where nobody looks for one.

What terminates where The browser connects over wss, the proxy terminates TLS and performs the upgrade, and a plaintext WebSocket is forwarded to the application over a private interface. What terminates where Browser wss:// — refuses ws:// from an HTTPS page client TLS handshake proxy presents the certificate, staples OCSP proxy HTTP upgrade requires HTTP/1.1 and a re-added Connection header proxy Plaintext tunnel loopback or private network only internal Application sees X-Forwarded-Proto, never real TLS app The application never sees TLS, so every security decision it makes depends on forwarded headers being correct
Encryption ends at the proxy. Everything above that line must be private by construction.

Renewal is the operation most likely to surprise you, because it happens unattended and its failure mode is silent until the certificate actually expires.

Renewal without dropping sockets A ninety-day certificate renewed at day sixty leaves a month of margin, and reloading rather than restarting keeps established tunnels alive on the old worker processes. Renewal without dropping sockets 90-day validity 30-day safety margin certificate issued (0day) renewal runs (60day) reload, not restart (60day) old workers still serving (62day) expiry, had renewal failed (90day) Monitor expiry independently of the renewal tool — a silent failure is a ninety-day fuse
Reload, never restart. The old workers keep their tunnels while new ones take the new certificate.

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.

Should I terminate TLS in Node instead? #

Rarely. Terminating at a proxy keeps cryptography off the event loop where it competes with message handling, and gives you OCSP stapling, session caching and certificate renewal without redeploying the application. Terminate in Node only when you need the application to see client certificates, or when there is genuinely no proxy in the deployment.

Does wss:// protect against everything https:// does? #

Against interception in transit, yes — it is the same TLS. What it does not do is bring the browser’s cross-origin protections with it: there is no preflight, and the response is fully readable cross-origin, so an origin check on the upgrade is required rather than optional. Transport security and authorisation are separate problems, and wss:// only solves the first.

What about certificate pinning for native clients? #

Useful and easy to get wrong. Pinning a leaf certificate breaks every renewal; pinning an intermediate or a public key survives renewals but breaks when the authority rotates. If you pin, pin to a key you control with a documented rotation plan and ship a backup pin, or a routine certificate change becomes an outage for every installed client.

Is permessage-deflate a security concern? #

It can be, in one specific shape: when attacker-influenced input and a secret share a compression context, compressed size leaks information about the secret, which is the CRIME and BREACH family of attacks. On a WebSocket that requires an unusual combination, but if your stream carries both a session token and arbitrary user text, disable context takeover or do not compress at all.

Back to Real-Time Protocol Selection & Architecture