How to check if websocket is secure

Point openssl s_client -connect host:port at the socket port and read the protocol line, then open the socket with curl -v wss://host/ and look for 101 Switching Protocols after the TLS handshake. A client that needs -k to connect is talking to a certificate no browser will accept.

Why check this

Run this on staging sign-off, and again after any change to the load balancer that terminates TLS. The failure it prevents is a real-time feature that carries a session token and chat content over ws:// because the socket URL was built from a hard-coded scheme while the page moved to HTTPS.

The check has two halves that fail separately. The wire half asks whether the port negotiates TLS and whose certificate it presents. The browser half asks whether the page would be allowed to open the socket at all. A test client answers the first and lies about the second, because every test client has a switch that turns certificate verification off and a browser does not.

Prerequisites

openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem -days 30 -subj "/CN=localhost" -addext "subjectAltName=DNS:localhost,IP:127.0.0.1"

wss-server.mjs serves the same one-line page over TLS on 9311 and over plain HTTP on 9312, and accepts a socket on both:

import { readFileSync } from 'node:fs';
import { createServer } from 'node:https';
import { createServer as createHttp } from 'node:http';
import { WebSocketServer } from 'ws';

const page = `<!doctype html><meta charset=utf-8><title>wss test</title><script>
const s = new WebSocket(location.hash.slice(1));
s.onopen = () => { console.log('open ' + s.url); s.send('ping'); };
s.onmessage = (e) => console.log('message ' + e.data);
s.onerror = () => console.log('error event');
s.onclose = (e) => console.log('close ' + e.code);
</script>`;

const secure = createServer(
  { cert: readFileSync('cert.pem'), key: readFileSync('key.pem') },
  (req, res) => res.end(page)
);
new WebSocketServer({ server: secure }).on('connection', (ws, req) => {
  console.log('wss connection, origin ' + (req.headers.origin ?? '(none)'));
  ws.on('message', (m) => ws.send('echo ' + m));
});
secure.listen(9311, '127.0.0.1', () => console.log('listening wss://127.0.0.1:9311'));

const plain = createHttp((req, res) => res.end(page));
new WebSocketServer({ server: plain }).on('connection', (ws) => {
  ws.on('message', (m) => ws.send('echo ' + m));
});
plain.listen(9312, '127.0.0.1', () => console.log('listening ws://127.0.0.1:9312'));

client.mjs connects with certificate verification on or off:

import WebSocket from 'ws';
const url = process.argv[2];
const trust = process.argv[3] !== 'insecure';
const ws = new WebSocket(url, { rejectUnauthorized: trust });
ws.on('open', () => { console.log('open ' + url); ws.send('ping'); });
ws.on('message', (m) => { console.log('message ' + m); ws.close(1000); });
ws.on('error', (e) => console.log('error ' + e.message));
ws.on('close', (c) => console.log('close ' + c));

mixed.mjs drives Chrome through the shared session helper, and takes the socket URL and the page URL as arguments:

import { open } from '../scripts/browser/session.mjs';
const target = process.argv[2];
const base = process.argv[3] ?? 'https://127.0.0.1:9311/';
const s = await open({
  args: ['--ignore-certificate-errors', '--host-resolver-rules=MAP wsdemo.test 127.0.0.1'],
});
try {
  await s.goto(base + '#' + target, { waitUntil: 'load' });
  await new Promise((r) => setTimeout(r, 2500));
  console.log('--- page ' + base + ' opening ' + target);
  for (const m of s.console) console.log(m);
} finally { await s.close(); }

Steps

  1. Step 1.

    Ask the socket port for a TLS handshake and read what it presents.

    echo | openssl s_client -connect 127.0.0.1:9311 -servername localhost 2>&1 | grep -E "verify error|subject=|issuer=|New,|Verify return code" | head -5
    
    verify error:num=18:self-signed certificate
    subject=CN = localhost
    issuer=CN = localhost
    New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
    Verify return code: 18 (self-signed certificate)

    New, TLSv1.3 is the only proof that the port speaks TLS at all. A subject equal to the issuer is the self-signed case, and code 18 names it.

  2. Step 2.

    Open the socket with the client's normal trust settings.

    curl -sS --max-time 3 wss://127.0.0.1:9311/
    
    curl: (60) SSL certificate OpenSSL verify result: self signed certificate (18)
    More details here: https://curl.se/docs/sslcerts.html

    Exit code 60 is the verdict a browser reaches too. No frame is ever sent.

  3. Step 3.

    Repeat with verification disabled, to separate a certificate problem from a socket problem.

    curl -v --max-time 3 -k wss://127.0.0.1:9311/ 2>&1 | grep -E "SSL connection using|subject:|issuer:|verification failed|^< HTTP|^< Upgrade|^< Sec-WebSocket-Accept"
    
    * SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384 / [blank] / UNDEF
    *   subject: CN=localhost
    *   issuer: CN=localhost
    *  SSL certificate verification failed, continuing anyway!
    < HTTP/1.1 101 Switching Protocols
    < Upgrade: websocket
    < Sec-WebSocket-Accept: MqDH+eQbtBvdpN3QzMENlREB0Hw=

    The upgrade succeeds, so the socket works and only the certificate is wrong. curl then waits for frames and ends on the timeout with exit code 28.

  4. Step 4.

    Connect a library client with verification left on.

    node client.mjs wss://localhost:9311/
    
    error self-signed certificate
    close 1006

    Close code 1006 means the connection ended without a close frame, which is what a handshake that never completed looks like from the client side.

  5. Step 5.

    Connect again with verification disabled, which is the line to look for in real client code.

    node client.mjs wss://localhost:9311/ insecure
    
    open wss://localhost:9311/
    message echo ping
    close 1000
  6. Step 6.

    Load a page over HTTPS and have it open a plain ws:// socket, with both addressed by a name.

    node mixed.mjs ws://wsdemo.test:9312/ https://wsdemo.test:9311/
    
    --- page https://wsdemo.test:9311/ opening ws://wsdemo.test:9312/
    error: Mixed Content: The page at 'https://wsdemo.test:9311/#ws://wsdemo.test:9312/' was loaded over HTTPS, but attempted to connect to the insecure WebSocket endpoint 'ws://wsdemo.test:9312/'. This request has been blocked; this endpoint must be available over WSS.
    pageerror: SecurityError: Failed to construct 'WebSocket': An insecure WebSocket connection may not be initiated from a page loaded over HTTPS.
  7. Step 7.

    Run the same check against the same server through the loopback address instead of the name.

    node mixed.mjs ws://127.0.0.1:9312/ https://127.0.0.1:9311/
    
    --- page https://127.0.0.1:9311/ opening ws://127.0.0.1:9312/
    log: open ws://127.0.0.1:9312/
    log: message echo ping

    Same page, same server, same browser. Only the spelling of the host changed, and the insecure socket now opens.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | New, TLSv1.3 and Verify return code: 0 | The port terminates TLS with a chain the client trusts | Nothing. Record the TLS version. | | Verify return code: 18 | TLS is on, the certificate chains to nothing | Replace it before anyone but you connects. A self-signed socket forces every client to disable verification. | | curl: (60) without -k, 101 with -k | The socket is sound, the certificate is not | Fix the certificate, not the socket code. | | close 1006 with no close reason | The connection ended before a close frame, usually at the TLS or upgrade stage | Read the error event. 1006 on its own never names a cause. | | Chrome blocks ws:// from an HTTPS page | The mixed content rule applied | Change the client to build the socket URL from location.protocol. | | Chrome allows ws:// from an HTTPS page | The host is loopback, so the rule did not apply | Rerun against a hostname. The pass proved nothing. |

Common mistakes

Sign: The mixed content check passes on localhost and the same build is blocked on staging.Cause: Chrome treats 127.0.0.1 as a potentially trustworthy origin, so a ws:// socket to loopback from an HTTPS page is not mixed content. Steps 6 and 7 show the identical page blocked under a name and allowed under the address.
Sign: The socket works in the integration suite and the browser refuses the same URL.Cause: The suite passes rejectUnauthorized: false to get past a development certificate. A browser has no equivalent switch, so it stops at the handshake. Any client option that skips verification hides the defect this page exists to find.
Sign: Node's global WebSocket reports an error and the reconnect loop never runs.Cause: When the TLS handshake fails, the global client fires error and never fires close, so a loop keyed to onclose waits forever. The ws package fires close 1006 in the same situation, which is why the two clients disagree about the same URL.

What to check next

FAQ

How to test a wss connection without a browser?

Steps 1 to 5 are the test. openssl proves TLS, curl proves the upgrade, and a ws client proves that a real library accepts the certificate. None of them proves the browser will, which is what steps 6 and 7 cover.

What does "websocket connection to wss failed" mean?

It is the browser's message for any handshake that did not reach 101, and it names no cause. Run step 2 against the same URL. Exit code 60 is a certificate, a 401 or 403 is the server rejecting the upgrade, and a timeout is a proxy.

Is wss the same as HTTPS?

The handshake is an HTTP request over the same TLS connection, so the certificate rules, the cipher and the version are identical. After the 101 the traffic is WebSocket frames inside that TLS session, which no HTTP proxy or HTTP log will show you.

Why does a self-signed certificate matter if the data is still encrypted?

Encryption without verification stops nobody who can answer for the address. It also trains every client to turn verification off, and that switch stays in the code long after the environment gets a real certificate.

Verified

Verified by Maks Vernyopenssl 3.1.1curl 8.21.0node 22.23.2ws 8.21.3Chrome 152.0.7977.76

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate9 minpublished updated Maks Verny