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.
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.
Renewal is the operation most likely to surprise you, because it happens unattended and its failure mode is silent until the certificate actually expires.
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.
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.