How to test websocket with curl

Run curl -sv --max-time 2 ws://host/path on a build whose Protocols line lists ws. The verbose log prints the upgrade request, the Sec-WebSocket-Key curl generated, and the 101 response with its Sec-WebSocket-Accept. A build without ws answers curl: (1) Protocol "ws" not supported.

Why check this

Run this when a socket fails and the client library reports a network error with no status code, and whenever a new proxy, gateway or TLS terminator lands in front of a WebSocket service. curl prints both halves of the upgrade, so it is the client that tells you what an intermediary changed.

The failure it prevents is a misread blame line. A load balancer that answers the upgrade itself returns a valid 101 and forwards nothing, and client libraries report that as a working connection until frames fail to arrive. curl shows the key it sent next to the accept value that came back, the one field an intermediary cannot fake without doing the hash.

Prerequisites

// echo-server.mjs   node 22, npm i ws@8
import { createServer } from 'node:http';
import { WebSocketServer } from 'ws';

const server = createServer((req, res) => {
  res.writeHead(426, { 'content-type': 'text/plain', 'sec-websocket-version': '13' });
  res.end('this endpoint speaks WebSocket only\n');
});

const wss = new WebSocketServer({ server, path: '/ws' });
wss.on('connection', (ws) => ws.on('message', (d) => ws.send('echo:' + d)));
server.listen(19317, '127.0.0.1', () => console.log('ws://127.0.0.1:19317/ws'));
openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem -days 7 -subj "//CN=localhost" -addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
// tls-server.mjs   the same echo server behind TLS, self-signed certificate
import { createServer } from 'node:https';
import { readFileSync } from 'node:fs';
import { WebSocketServer } from 'ws';

const server = createServer({ key: readFileSync('key.pem'), cert: readFileSync('cert.pem') });
const wss = new WebSocketServer({ server, path: '/ws' });
wss.on('connection', (ws) => ws.on('message', (d) => ws.send('echo:' + d)));
server.listen(19318, '127.0.0.1', () => console.log('wss://127.0.0.1:19318/ws'));

Steps

  1. Step 1.

    Read the protocol list of the curl you are about to use.

    curl --version | sed -n '1p;3p'
    
    curl 8.21.0 (x86_64-w64-mingw32) libcurl/8.21.0 LibreSSL/4.3.2 zlib/1.3.1.zlib-ng brotli/1.2.0 zstd/1.5.7 WinIDN libpsl/0.23.0 libssh2/1.11.1 nghttp2/1.70.0 ngtcp2/1.25.0 nghttp3/1.18.0 WinLDAP
    Protocols: dict file ftp ftps gopher gophers http https imap imaps ipfs ipns ldap ldaps mqtt mqtts pop3 pop3s rtsp scp sftp smtp smtps telnet tftp ws wss

    ws and wss at the end of the second line are the whole prerequisite. Support is compiled in, not switched on at runtime, so a build without them cannot be persuaded.

  2. Step 2.

    Run the same URL on a build that has no WebSocket support, so you recognise the message. This one is the Git Bash curl 8.1.2, whose Protocols line ends at tftp.

    curl -sS ws://127.0.0.1:19317/ws
    
    curl: (1) Protocol "ws" not supported or disabled in libcurl

    Exit code 1 and no request on the wire, so nothing about the endpoint has been tested. Install a newer build, or read the handshake over http://, which any curl can do.

  3. Step 3.

    Open the socket and read the upgrade in full.

    curl -sv --max-time 2 ws://127.0.0.1:19317/ws
    
    *   Trying 127.0.0.1:19317...
    * Established connection to 127.0.0.1 (127.0.0.1 port 19317) from 127.0.0.1 port 56772
    * using HTTP/1.x
    > GET /ws HTTP/1.1
    > Host: 127.0.0.1:19317
    > User-Agent: curl/8.21.0
    > Accept: */*
    > Upgrade: websocket
    > Sec-WebSocket-Version: 13
    > Sec-WebSocket-Key: BkYEQeYfyNnokoQ53dE5NQ==
    > Connection: Upgrade
    >
    * Request completely sent off
    < HTTP/1.1 101 Switching Protocols
    < Upgrade: websocket
    < Connection: Upgrade
    < Sec-WebSocket-Accept: e5dKGeXfFTXhv34Y3c7ADXht4Bg=
    <
    * Received 101, Switching to WebSocket
    * [WS] Received 101, switch to WebSocket
    * Operation timed out after 2008 milliseconds with 0 bytes received
    * closing connection #0

    curl built the upgrade request itself: the scheme chose Upgrade: websocket, version 13 and a fresh random key. The timeout on the last line is not a failure. The socket was open and idle, because curl has no frame to send.

  4. Step 4.

    Recompute the accept value from the key in the request above. The server appends the fixed GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11, hashes with SHA-1 and encodes base64.

    printf '%s' 'BkYEQeYfyNnokoQ53dE5NQ==258EAFA5-E914-47DA-95CA-C5AB0DC85B11' | openssl sha1 -binary | openssl base64
    
    e5dKGeXfFTXhv34Y3c7ADXht4Bg=

    It matches the header in step 3, so the peer that answered ran the WebSocket handshake rather than returning a 101 from a rule. The key changes on every run, so copy the one from your own output.

  5. Step 5.

    Repeat over TLS. The local certificate is self-signed, so -k is needed; against a real endpoint, leave it out and let verification run.

    curl -sv --max-time 3 -k wss://127.0.0.1:19318/ws
    
    *   Trying 127.0.0.1:19318...
    …
    * SSL Trust: peer verification disabled
    …
    * SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384 / [blank] / UNDEF
    * Server certificate:
    *   subject: CN=localhost
    *   start date: Sep 11 22:43:29 2026 GMT
    *   expire date: Sep 18 22:43:29 2026 GMT
    *   issuer: CN=localhost
    …
    * OpenSSL verify result: 12
    *  SSL certificate verification failed, continuing anyway!
    …
    > GET /ws HTTP/1.1
    …
    > Upgrade: websocket
    > Sec-WebSocket-Version: 13
    > Sec-WebSocket-Key: ERBaCza/EGMzhnvtlY7KJw==
    > Connection: Upgrade
    …
    < HTTP/1.1 101 Switching Protocols
    < Upgrade: websocket
    < Connection: Upgrade
    < Sec-WebSocket-Accept: KV+sz/gVkE8nG/xte078hunCBDM=
    …
    * [WS] Received 101, switch to WebSocket
    * Operation timed out after 3008 milliseconds with 0 bytes received
    * closing connection #0

    TLS finishes before the upgrade starts, so a wss:// failure is usually a certificate problem rather than a WebSocket one. Without -k the same command stops at verify result: self signed certificate (18) and never sends the GET.

  6. Step 6.

    Try to send a frame with -d, and watch curl ignore it.

    curl -sv --max-time 3 ws://127.0.0.1:19317/ws -d 'hello from curl'
    
    *   Trying 127.0.0.1:19317...
    * Established connection to 127.0.0.1 (127.0.0.1 port 19317) from 127.0.0.1 port 60847
    * using HTTP/1.x
    > GET /ws HTTP/1.1
    > Host: 127.0.0.1:19317
    > User-Agent: curl/8.21.0
    > Accept: */*
    > Upgrade: websocket
    > Sec-WebSocket-Version: 13
    > Sec-WebSocket-Key: kLnhaAtZ4YT96OJr7MNEng==
    > Connection: Upgrade
    >
    * Request completely sent off
    < HTTP/1.1 101 Switching Protocols
    < Upgrade: websocket
    < Connection: Upgrade
    < Sec-WebSocket-Accept: wDNuiBjd4FYqqCvkRK7F9473PKY=
    <
    * Received 101, Switching to WebSocket
    * [WS] Received 101, switch to WebSocket
    * Operation timed out after 3013 milliseconds with 0 bytes received
    * closing connection #0

    The request is still a GET with no body, and the echo server answered nothing, because no frame arrived. No warning is printed. For a round trip, use a client that writes frames, such as npx wscat@6.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | [WS] Received 101, switch to WebSocket | The upgrade completed | Verify the accept value, then send frames with another client | | curl: (1) Protocol "ws" not supported | Your curl has no WebSocket support | Install 8.21.0 or read the handshake over http:// | | 101 with an accept value that fails step 4 | Something answered 101 without doing the hash | Suspect the proxy or gateway, not the application | | The response is 200, 404 or 426 | The upgrade never reached a WebSocket route | Check the path and whether the Upgrade header survives | | curl: (28) after the 101 | The socket is open and idle | Expected. curl cannot send a frame to provoke a reply. | | verify result: self signed certificate (18) | TLS failed before the upgrade | Fix the certificate first. The socket is not the problem. |

Common mistakes

Sign: curl -d or --data on a ws:// URL sends nothing and reports no error.Cause: The command line tool opens the socket and then reads. Body options are dropped silently, the request stays a GET, and the server sees no frame. Step 6 shows an echo server answering with nothing at all.
Sign: curl on this machine says the protocol is unsupported while a colleague's curl works.Cause: WebSocket support is a compile-time option, so two builds of the same version differ. The Schannel build in Git Bash 8.1.2 has no ws, the winget build 8.21.0 has ws and wss. Check the Protocols line before reporting an endpoint as broken.
Sign: Exit code 28 after a clean 101 gets logged as a failed connection.Cause: --max-time expiring on an idle open socket is the normal end of a curl WebSocket run. Grade the check on the 101 and the accept value, not on the exit code, or a green endpoint fails your pipeline every time.

What to check next

FAQ

Can curl send and receive WebSocket messages?

The library can. The command line tool opens the connection and prints what arrives, with no option for writing a frame, as step 6 shows. Use wscat for a round trip.

How to test wss with curl?

Same command with the wss:// scheme. TLS is verified first, so a self-signed certificate stops the run before the upgrade. Add -k only against a local endpoint.

How do I test a websocket connection with curl on an old build?

Send the handshake yourself over http:// with -H 'Connection: Upgrade', -H 'Upgrade: websocket', a key and a version. Any build does that, because it never touches the WebSocket code.

Why does curl always report a timeout?

Because a WebSocket server sends nothing until it is spoken to, and curl never speaks. Use --max-time to end the run, and read the 101 above it.

Does a 101 from curl prove the endpoint works?

It proves the handshake works. Recompute the accept value to prove the peer ran the hash, then send a frame with another client to prove the application is listening.

Verified

Verified by Maks Vernycurl 8.21.0curl 8.1.2openssl 3.1.1node 22.23.2ws 8.21.3

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.

intermediate7 minpublished updated Maks Verny