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:
- A working WebSocket server using the
wspackage or a framework built on it. If you are still deciding whether WebSockets are the right transport at all, start with the WebSocket vs SSE vs WebRTC comparison. - A correct upgrade path through your proxy —
UpgradeandConnectionheaders forwarded intact. The mechanics live in Protocol Handshake Mechanics and the nginx specifics in Browser Compatibility & Polyfills. - A way to issue and renew TLS certificates (ACME/Let’s Encrypt, or an internal PKI). Origin validation here stops at the transport boundary; token-based identity is a separate layer covered in WebSocket Authentication & Authorization.
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.
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 aws://socket — browsers block it as mixed content — but code that builds the URL fromlocation.protocoland gets it wrong fails silently in some embedded webviews. Always derive the scheme:location.protocol === "https:" ? "wss:" : "ws:". Originis absent for non-browser clients. Native mobile apps, server-to-server clients, andcurlsend noOriginheader. 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-Protobecomes the only signal that the original hop was encrypted. Trust that header only from known proxy IPs, or an attacker can spoofhttpsover a plain connection. - Certificate reload races.
watchcan fire while ACME is mid-write, handing you a half-written PEM. WrapsetSecureContextin 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 #
- Handling cross-origin WebSocket connections — when a strict allowlist is too blunt: matching subdomains, supporting native clients without an
Origin, and pairing origin checks with token validation safely.
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.
Related #
- Handling cross-origin WebSocket connections — the child topic on origin matching, subdomains, and non-browser clients.
- WebSocket Authentication & Authorization — validating JWTs and sessions on the upgrade, the identity layer above transport.
- Protocol Handshake Mechanics — the upgrade request and
101response this guide secures. - Browser Compatibility & Polyfills — nginx upgrade headers and behavior across older user agents.
- WebSocket vs SSE vs WebRTC comparison — the security models of each transport, for choosing among them.