Websocket handshake failed

A failed handshake is an ordinary HTTP response, so read it with curl over http://: curl -i -H 'Connection: Upgrade' -H 'Upgrade: websocket' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' -H 'Sec-WebSocket-Version: 13' http://host/ws. A working server answers 101 with Sec-WebSocket-Accept. Anything else carries the reason in its status line and body.

Why check this

Run this when a socket that works on a developer machine fails after deployment, and on staging sign-off for any environment with a proxy, an ingress or a load balancer in front of the service. The handshake is the only part of a WebSocket session that speaks HTTP, so it is the only part an intermediary can rewrite.

The failure it prevents is specific. A reverse proxy configured without proxy_set_header Upgrade turns the upgrade into a plain GET, the application answers it as a normal route, and the client retries in a loop. Logs show 200s, the dashboard stays green, and the feature is dead.

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',
  verifyClient: ({ origin }) => origin === undefined || origin === 'https://app.example.com',
});

wss.on('connection', (ws, req) => {
  console.log('open key=%s origin=%s', req.headers['sec-websocket-key'], req.headers.origin ?? '(none)');
  ws.on('message', (data) => ws.send('echo:' + data));
});

server.listen(19317, '127.0.0.1', () => console.log('ws://127.0.0.1:19317/ws'));
// fake-101.mjs   a server that answers 101 with a wrong Sec-WebSocket-Accept
import { createServer } from 'node:net';
createServer((socket) => {
  socket.once('data', () => {
    socket.write(
      'HTTP/1.1 101 Switching Protocols\r\n' +
      'Upgrade: websocket\r\n' +
      'Connection: Upgrade\r\n' +
      'Sec-WebSocket-Accept: AAAAAAAAAAAAAAAAAAAAAAAAAAA=\r\n\r\n'
    );
  });
}).listen(19319, '127.0.0.1', () => console.log('fake 101 on 19319'));

Steps

  1. Step 1.

    Send a complete handshake and print the response headers. The key is the example from RFC 6455, so its accept value is published and fixed.

    curl -sS -i --http1.1 --max-time 2 -H 'Connection: Upgrade' -H 'Upgrade: websocket' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' -H 'Sec-WebSocket-Version: 13' http://127.0.0.1:19317/ws
    
    HTTP/1.1 101 Switching Protocols
    Upgrade: websocket
    Connection: Upgrade
    Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
    
    curl: (28) Operation timed out after 2003 milliseconds with 0 bytes received

    Four fields, each load bearing. 101 Switching Protocols ends the HTTP request. Upgrade: websocket and Connection: Upgrade name the protocol the socket now carries. Sec-WebSocket-Accept is the proof, and step 2 checks it.

  2. Step 2.

    Recompute the accept value from the key you sent. The server appends the fixed GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11, takes the SHA-1, and base64 encodes it.

    printf '%s' 'dGhlIHNhbXBsZSBub25jZQ==258EAFA5-E914-47DA-95CA-C5AB0DC85B11' | openssl sha1 -binary | openssl base64
    
    s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

    It matches the header in step 1. Any HTTP server can write a 101 status line, so only a peer that ran the hash over the key you chose can produce this string.

  3. Step 3.

    Point a client at a server that returns 101 with a wrong accept value. Start fake-101.mjs, then run Node's built-in WebSocket client against it.

    node -e 'const ws=new WebSocket(process.argv[1]);ws.onerror=(e)=>console.log("error:",e.message);setTimeout(()=>process.exit(0),1500)' ws://127.0.0.1:19319/ws
    
    error: Incorrect hash received in Sec-WebSocket-Accept header.

    The client refuses the connection even though the status line said 101. A proxy that rewrites Sec-WebSocket-Key on the way in produces this error, and so does a mock server whose accept value was pasted from a tutorial.

  4. Step 4.

    Remove the Upgrade header and send the same request. This is what a proxy that drops the header leaves the server with.

    curl -sS -i --http1.1 --max-time 3 http://127.0.0.1:19317/ws
    
    HTTP/1.1 426 Upgrade Required
    content-type: text/plain
    sec-websocket-version: 13
    Date: Fri, 11 Sep 2026 22:47:13 GMT
    Connection: keep-alive
    Keep-Alive: timeout=5
    Transfer-Encoding: chunked
    
    this endpoint speaks WebSocket only

    The WebSocket layer never saw this request. Node routed it to the ordinary request handler, so the status is whatever the application returns for a GET on that path. The 426 above is this server's choice; many applications answer 200 with a page, which is why a stripped header reads as healthy in a log.

  5. Step 5.

    Offer a protocol version the server does not implement.

    curl -sS -i --http1.1 --max-time 3 -H 'Connection: Upgrade' -H 'Upgrade: websocket' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' -H 'Sec-WebSocket-Version: 12' http://127.0.0.1:19317/ws
    
    HTTP/1.1 400 Bad Request
    Connection: close
    Content-Type: text/html
    Content-Length: 47
    Sec-WebSocket-Version: 13, 8
    
    Missing or invalid Sec-WebSocket-Version header

    The response names the versions the server accepts, 13, 8, in a header. Read it before changing the client.

  6. Step 6.

    Send a key that is not 16 random bytes in base64.

    curl -sS -i --http1.1 --max-time 3 -H 'Connection: Upgrade' -H 'Upgrade: websocket' -H 'Sec-WebSocket-Key: abc' -H 'Sec-WebSocket-Version: 13' http://127.0.0.1:19317/ws
    
    HTTP/1.1 400 Bad Request
    Connection: close
    Content-Type: text/html
    Content-Length: 43
    
    Missing or invalid Sec-WebSocket-Key header

    A client that reuses one constant key still passes this check, because the server only measures the decoded length.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | 101 and an accept value that matches step 2 | The peer is a real WebSocket server | Move on to frames and close codes | | 101 and an accept value that does not match | Something between you and the server rewrote the key | Compare the key in the request with the one in the server log | | 400 with Missing or invalid Sec-WebSocket-Version header | The version you offered is not implemented | Use a version from the Sec-WebSocket-Version response header | | 400 with Missing or invalid Sec-WebSocket-Key header | The key was absent or the wrong length | Check whether a proxy strips headers it does not recognise | | 401 Unauthorized on a correct handshake | The origin allow list rejected you | Send the Origin the application expects | | 426, 200 or an HTML page | The request was handled as a plain GET | The Upgrade header was dropped before the WebSocket route |

Common mistakes

Sign: The client library reports one message, 'Received network error or non-101 status code', for every failure.Cause: Node's built-in WebSocket collapses a closed port, a 400 and a 401 into the same string. The status and the body exist on the wire, and only a raw request shows them. Reproduce the failure with curl before reading the client message as a diagnosis.
Sign: A 401 comes back from an endpoint that has no authentication.Cause: ws 8 answers a rejected verifyClient with 401 Unauthorized, not 403. On a socket URL a 401 usually points at the origin allow list rather than at a missing token, and the body is the single word Unauthorized with no hint about the origin.
Sign: curl exits 28 after printing the 101, and it looks like a failed handshake.Cause: The handshake succeeded. The server has nothing to send until the client sends a frame, and curl cannot send one, so --max-time expires on an idle open socket. Read the headers, not the exit code.

What to check next

FAQ

What does a websocket handshake error mean?

It means the request that opens the socket got a response other than 101, or a 101 the client rejected. Browsers print the status in the console; curl prints the status and the body.

Why does a websocket handshake time out?

A timeout with no response at all means the request never reached an application that answers upgrades, usually a proxy holding it open or a firewall dropping it. A timeout after a 101 is not a failure, as step 1 shows.

Which status code says the endpoint is not a WebSocket server?

There is no dedicated code. The request arrives as a GET, so you get whatever that route returns: 200, 404 or 426. Only the absence of Upgrade: websocket in the response settles it.

Can I check the handshake in the browser instead?

Chrome shows the upgrade request and response headers in the Network tab. How to check websocket connection in chrome has the click path.

Does Sec-WebSocket-Accept protect anything?

It is not a security control. It stops a client from sending frames to a peer that answered 101 without understanding the protocol, such as a cache or a misconfigured proxy.

Verified

Verified by Maks Vernycurl 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.

intermediate8 minpublished updated Maks Verny