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.

Where identity is established The device sends its certificate and a signature proving possession of the private key during the TLS handshake; the terminator verifies chain, expiry and revocation, then forwards the upgrade with the verified identity to the WebSocket server. Where identity is established Device TLS terminator WS server ClientHello server cert + CertificateRequest client cert + CertificateVerify (signed) verify chain, expiry, revocation upgrade + verified identity 101 Switching Protocols A client without a valid certificate never reaches the WebSocket layer
Authentication happens inside the handshake, before any application code runs.

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.

A device certificate's life A device receives a ninety-day certificate, renews it automatically at two thirds of its life at day sixty and day one hundred twenty, and when compromised at day two hundred ten is revoked and its open sockets closed the next day. A device certificate's life issued (90-day cert) (0 d) auto-renew at 2/3 life (60 d) renewed again (120 d) device compromised (210 d) revoked + sockets closed (211 d) Short lifetimes limit exposure even when revocation checks fail
Short-lived certificates with automatic renewal, plus revocation that reaches open sockets.

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.

Machine-client authentication options Comparison of shared API keys, per-device tokens, mTLS with keys on disk and mTLS with keys in a TPM on leakage through logs, per-device revocation and whether the key can be extracted. Machine-client authentication options Leaks via logs Per-device revoke Key extractable Shared API key yes no yes Per-device token if in URL yes yes mTLS, key on disk no yes with root access mTLS, key in TPM no yes no mTLS with hardware-backed keys is the only option where stealing the device's files is not enough
Proof of possession beats bearer secrets for machines.

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.

Back to Security & TLS Configuration.