Browser WebSocket (wss://) fails with "WebSocket opening handshake was canceled" in Chrome, while ordinary HTTPS requests to the same host succeed. nginx logs no access-log entry for the upgrade request at all, and the error log is silent, because the connection dies at the TLS layer before any HTTP request completes. Server-side WebSocket code is correct and returns proper HTTP responses (e.g. 401) when tested with a non-browser client such as curl or the ws Node library.
Fix: Root cause: the nginx server block terminating the WebSocket has `ssl_verify_client optional` (or `on`), so nginx sends a TLS CertificateRequest to every client on that vhost. Chrome cancels any WebSocket handshake that receives a certificate selection request when no certificate selection is already cached for that origin, because Chromium policy forbids showing UI for subresources — and a WebSocket is a subresource. Ordinary XHR/fetch to the same host is unaffected, which is what makes it look like a WebSocket-specific server bug. `ssl_verify_client` is server-scoped in nginx: the CertificateRequest is sent during the TLS handshake, before any path is known, so no `location` block can exempt the WebSocket endpoint. The fix is to split the vhost by audience — a browser-facing server_name with no `ssl_client_certificate` / `ssl_verify_client` directives at all, and a separate internal server_name with `ssl_verify_client on` for service-to-service mTLS callers. Important security note when splitting: if the application trusts `X-SSL-Client-Verify` / `X-SSL-Client-Subject` headers set by nginx, the browser-facing vhost must explicitly blank those headers (`proxy_set_header X-SSL-Client-Verify "";`). Otherwise removing the client-cert directives leaves those headers attacker-controlled, since nginx no longer overwrites them. Diagnostic that identifies this quickly: `openssl s_client -connect HOST:443 -servername HOST </dev/null | grep -A3 "Acceptable client certificate CA names"`. If the CA list prints, the vhost is requesting client certificates and Chrome WebSockets to it will be canceled.
nginxwebsocketmtlsssl_verify_clientchromechromiumclient-certificatetls
References
- https://issues.chromium.org/issues/41081027 — Chromium tracker issue 'Secure Websocket with client certificate not working' — the WebSocket stack cancels the request if it receives a certificate selection request when there is no preceding cached certificate selection, because Chromium policy is to not show UI for subresources.
- https://issues.chromium.org/issues/40640440 — Chromium tracker issue 'WebSocket cancels connections requiring Client Certs' — Chrome closes the connection because it would need to open a certificate selection dialog, which is not permitted for WebSockets.
- https://github.com/beyondcode/laravel-websockets/issues/266 — Thread diagnosing 'WebSocket opening handshake was canceled' in Chrome as caused by the server requesting client certificates, quoting Chromium source: 'since WebSockets are sub-resources they should not pop-up UI and so there is nothing more we can do', and identifying nginx ssl_verify_client as the misconfiguration.