Mutual TLS for WebSocket clients #
A fleet of IoT gateways keeps a WebSocket open to your backend, and so do a handful of internal services. Today they authenticate with a shared API key in the URL — the same key on every device, copied into firmware, visible in proxy logs, and impossible to revoke for one device without rotating it on all of them. For machine clients, mutual TLS (mTLS) is the stronger model: each client holds its own private key and certificate, proves possession during the TLS handshake, and the server derives the client’s identity from the certificate before a single WebSocket byte is exchanged. There is no bearer secret to leak, and each device can be revoked individually.
Root cause #
Ordinary TLS authenticates only the server: the client checks the server’s certificate, and the server learns nothing about who the client is. Application-level credentials — API keys, JWTs — then identify the client inside the encrypted channel. Those are bearer credentials: whoever holds the string is the client. Shared across devices, embedded in firmware, passed in URLs, they leak and cannot be scoped or revoked per device.
With mTLS, the server requests a certificate during the handshake, and the client must sign handshake data with the matching private key. The key never leaves the device (ideally it lives in a secure element or TPM), so capturing traffic or logs yields nothing reusable. The certificate carries the identity, and the server can check it against a certificate authority you control, a revocation list, and an allowlist of expected subjects.
Resolution #
Issue client certificates from a private CA dedicated to this purpose, terminate TLS where client verification is configured, and pass the verified identity to the WebSocket server. Two common layouts follow: terminating at nginx and forwarding identity headers, or terminating directly in Node.js.
# nginx terminates mTLS and forwards the verified identity to the WebSocket backend.
server {
listen 443 ssl;
server_name devices.example.com;
ssl_certificate /etc/tls/server.crt;
ssl_certificate_key /etc/tls/server.key;
ssl_client_certificate /etc/tls/device-ca.crt; # only this CA's certs are accepted
ssl_verify_client on; # no valid cert, no connection
ssl_verify_depth 2;
ssl_crl /etc/tls/device-ca.crl; # revoked device certificates
location /ws/ {
proxy_pass http://device_gateway;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Identity from the verified certificate. The backend must ONLY accept these
# headers from nginx (bind it to a private interface or check the source).
proxy_set_header X-Client-Verify $ssl_client_verify;
proxy_set_header X-Client-Subject $ssl_client_s_dn;
proxy_set_header X-Client-Serial $ssl_client_serial;
}
}
// Alternatively, terminate mTLS directly in Node.js and read the peer certificate.
import https from 'node:https';
import { readFileSync } from 'node:fs';
import type { TLSSocket } from 'node:tls';
import { WebSocketServer } from 'ws';
const server = https.createServer({
cert: readFileSync('/etc/tls/server.crt'),
key: readFileSync('/etc/tls/server.key'),
ca: readFileSync('/etc/tls/device-ca.crt'), // trust anchor for client certs
requestCert: true,
rejectUnauthorized: true, // handshake fails without a valid client cert
});
const wss = new WebSocketServer({ noServer: true });
const revokedSerials = new Set<string>(); // fed from your CRL or device registry
server.on('upgrade', (req, socket, head) => {
const cert = (req.socket as TLSSocket).getPeerCertificate();
const deviceId = cert?.subject?.CN; // e.g. CN=gw-0419
if (!deviceId || revokedSerials.has(cert.serialNumber)) {
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req, { deviceId, serial: cert.serialNumber }));
});
server.listen(8443);
Treat the certificate’s identity exactly like any other authenticated identity: authorize per device what it may subscribe to and publish, as in per-channel authorization for WebSocket subscriptions. mTLS answers “which device is this”; it does not answer “what may it do”.
Revocation needs a path that reaches long-lived connections. A CRL or OCSP check happens at handshake time, but a revoked device that is already connected stays connected until it reconnects. When you revoke a certificate, also close that device’s open sockets by serial number, the same pattern as session revocation in cookie session authentication for WebSockets.
Edge cases #
Browsers. Browsers support client certificates but present a certificate picker to the user and offer no JavaScript control, which makes mTLS a poor fit for consumer web apps. Use it for devices, services and managed enterprise desktops; use tokens or sessions for browsers.
Proxies in the path. Any TLS-terminating hop before your verifier — a CDN, a cloud load balancer in HTTPS mode — ends the client’s TLS session, and the client certificate never reaches you. Either terminate mTLS at the first hop (some managed load balancers support it and forward identity headers), or use TCP passthrough to the verifier.
Header spoofing. Forwarded identity headers are only trustworthy if nothing but the verifying proxy can reach the backend. Bind the backend to a private network, or have nginx sign the headers, and strip any client-supplied copies at the edge.
Verification #
Test the three cases the server must distinguish:
# 1. No client certificate: the TLS handshake itself must fail.
curl -sv https://devices.example.com/ws/ 2>&1 | grep -iE 'alert|handshake|400'
# 2. Valid device certificate: expect 101 on an upgrade request.
curl -si --http1.1 --cert gw-0419.crt --key gw-0419.key https://devices.example.com/ws/ \
-H 'Connection: Upgrade' -H 'Upgrade: websocket' -H 'Sec-WebSocket-Version: 13' \
-H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' --max-time 2 | head -1
# 3. Revoked certificate (serial on the CRL): expect rejection.
curl -si --http1.1 --cert revoked.crt --key revoked.key https://devices.example.com/ws/ --max-time 2 | head -1
Then check the operational side: certificate expiry is monitored per device (a fleet where a thousand certificates expire the same day is an outage waiting to happen), renewal succeeds without reconnect storms, and a revocation closes the device’s live socket within seconds.
Operational checklist #
FAQ #
Can browsers use mutual TLS with WebSockets? #
Technically yes — the browser presents a client certificate if one is installed and the server requests it — but users see a certificate chooser and JavaScript cannot manage it. It suits managed enterprise desktops, not consumer web apps.
Does mTLS replace application authorization? #
No. It authenticates the client strongly; you still decide what each identity may do, per channel and per action.
How do I rotate certificates without reconnect storms? #
Renew well before expiry, at a random point in a window, and keep existing connections open — a new certificate is only needed for the next handshake. Avoid fleet-wide simultaneous expiry by staggering issuance.
Can a cloud load balancer do mTLS for WebSockets? #
Several managed load balancers now support client-certificate verification and forward the verified certificate details to targets in headers. Check that WebSocket upgrades are supported on the same listener, and apply the same header-trust rules.
Related #
- Terminating WSS with nginx and Let’s Encrypt — server-side TLS this builds on.
- Signing WebSocket Messages with HMAC — message-level integrity across relays.
- Per-Channel Authorization for WebSocket Subscriptions — what a verified device may do.
- Rate Limiting WebSocket Handshakes — protecting the handshake that mTLS makes more expensive.
Back to Security & TLS Configuration.