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
- Node 22 and ws 8, installed into an empty folder with
npm i ws@8. This page used ws 8.21.3. - A local echo server. Everything below runs against it, so no third-party host is touched:
// 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'));
- A second server for step 3, which returns 101 with a wrong accept value and nothing else:
// 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'));
- curl. Any build works here, including the Schannel 8.1.2 that has no
wsprotocol, because these requests usehttp://and never reach curl's WebSocket code. The captures below came from 8.1.2. - RFC 6455 section 4.1 for the field list the client must send.
Steps
- 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/wsHTTP/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 receivedFour fields, each load bearing.
101 Switching Protocolsends the HTTP request.Upgrade: websocketandConnection: Upgradename the protocol the socket now carries.Sec-WebSocket-Acceptis the proof, and step 2 checks it. - 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 base64s3pPLMBiTxaQ9kYGzzhZRbK+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.
- 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/wserror: 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-Keyon the way in produces this error, and so does a mock server whose accept value was pasted from a tutorial. - Step 4.
Remove the
Upgradeheader 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/wsHTTP/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 onlyThe 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.
- 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/wsHTTP/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 headerThe response names the versions the server accepts,
13, 8, in a header. Read it before changing the client. - 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/wsHTTP/1.1 400 Bad Request Connection: close Content-Type: text/html Content-Length: 43 Missing or invalid Sec-WebSocket-Key headerA 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
What to check next
- How to test websocket with curl: the same handshake driven through curl's own
ws://handler. - How to test websocket connection: what three clients report once the handshake passes.
- How to check websocket origin validation: the check behind the 401 in the table above.
- How to check if websocket is secure: the same fields over TLS.
- Websocket subprotocol: the one handshake header this page leaves out.
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.
Related on this site
intermediate8 minpublished updated Maks Verny