Sec-WebSocket-Key and Sec-WebSocket-Accept explained #
A custom client fails with Error during WebSocket handshake: Incorrect 'Sec-WebSocket-Accept' header value, or a hand-rolled server works with one library and not another, or a security review asks whether Sec-WebSocket-Key is authentication. The two headers are the most visible part of the RFC 6455 opening handshake, and the most misunderstood. They do one narrow job — proving that the server actually understood a WebSocket handshake rather than being an HTTP server or cache that echoed something back — and they provide no confidentiality, integrity or authentication at all. Knowing exactly how the value is derived turns a baffling handshake error into a one-line fix.
Root cause #
WebSocket runs over the same ports as HTTP and begins as an HTTP request, which creates a risk the protocol designers cared about: an HTTP intermediary or a non-WebSocket server might respond to an upgrade request in a way the client mistakes for success, and a script could then speak arbitrary bytes to something that was never meant to receive them. The key/accept exchange closes that gap. The client sends a random nonce; the server must transform it with a fixed algorithm that only a WebSocket implementation would know; the client checks the result. An echoing proxy or cached response cannot produce the right value for a fresh nonce.
The algorithm is fixed by RFC 6455: take the base64 Sec-WebSocket-Key string exactly as received, append the constant GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11, compute the SHA-1 hash of that string, and base64-encode the 20-byte digest. That is the Sec-WebSocket-Accept value.
Resolution #
Most handshake-value bugs come from implementing those four steps slightly wrong. The reference implementation below includes the RFC’s own test vector, so you can verify your code against it in a unit test.
import { createHash, randomBytes } from 'node:crypto';
// Fixed by RFC 6455 §1.3 — identical for every WebSocket implementation.
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
// Server side: compute the accept value from the client's key header.
export function acceptFor(secWebSocketKey: string): string {
return createHash('sha1')
.update(secWebSocketKey.trim() + WS_GUID) // append to the base64 TEXT — do not decode it
.digest('base64'); // base64 of the raw 20-byte digest, not hex
}
// Client side: generate a key and know what to expect back.
export function newKey(): { key: string; expectedAccept: string } {
const key = randomBytes(16).toString('base64'); // must decode to exactly 16 bytes
return { key, expectedAccept: acceptFor(key) };
}
// Server-side validation of the upgrade request, before computing the accept value.
export function validateUpgrade(headers: Record<string, string | undefined>): string | null {
const key = headers['sec-websocket-key'];
if (!key || Buffer.from(key, 'base64').length !== 16) return 'bad Sec-WebSocket-Key';
if (headers['sec-websocket-version'] !== '13') return 'unsupported version'; // answer 426 with Sec-WebSocket-Version: 13
if (!/\bupgrade\b/i.test(headers['connection'] ?? '')) return 'missing Connection: Upgrade';
if ((headers['upgrade'] ?? '').toLowerCase() !== 'websocket') return 'missing Upgrade: websocket';
return null;
}
// RFC 6455 test vector — keep this in your test suite.
console.assert(acceptFor('dGhlIHNhbXBsZSBub25jZQ==') === 's3pPLMBiTxaQ9kYGzzhZRbK+xOo=');
The full successful response then looks like this, and any byte-level difference — a missing \r\n, a status text other than what the client tolerates, a lowercase header value some strict clients reject — can break a hand-written server:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
If a subprotocol was requested and accepted, the response also carries exactly one Sec-WebSocket-Protocol value from the client’s list; see WebSocket subprotocol negotiation. In practice, prefer a maintained library over writing this by hand — ws and every mainstream server get it right — but when you are debugging a proxy, an embedded client or an interop problem, the derivation is what you check first.
What the handshake does not do #
Because the headers look like a challenge–response, they are often mistaken for security. They are not:
- No authentication. Anyone can compute the accept value for any key; there is no secret. Identity must come from cookies, tokens or client certificates — see validating JWT on the WebSocket upgrade.
- No confidentiality or integrity. SHA-1 is used here only as a fixed, well-known transform; its cryptographic weaknesses are irrelevant because nothing depends on it being hard to compute. Use
wss://for encryption. - No cross-origin protection. The browser sends
Origin, but the key/accept exchange does not check it. The server must, as covered in enforcing origin and CSRF checks on WebSockets.
The related masking of client-to-server frames is similarly not security for your application: it exists to stop crafted frames from poisoning intermediary caches, and browsers apply it automatically.
Verification #
Reproduce the handshake with a known key and compare the accept value by hand:
KEY='dGhlIHNhbXBsZSBub25jZQ=='
# Expected accept, computed locally:
printf '%s258EAFA5-E914-47DA-95CA-C5AB0DC85B11' "$KEY" | openssl sha1 -binary | base64
# What the server actually returns:
curl -si --http1.1 https://rt.example.com/ws \
-H 'Connection: Upgrade' -H 'Upgrade: websocket' -H 'Sec-WebSocket-Version: 13' \
-H "Sec-WebSocket-Key: $KEY" --max-time 2 | grep -i sec-websocket-accept
Both must print s3pPLMBiTxaQ9kYGzzhZRbK+xOo=. If the server’s value differs, check (in this order) whether the key is being decoded before hashing, whether the digest is hex rather than base64, whether whitespace was not trimmed, and whether a proxy rewrote the key header — some middleboxes regenerate Sec-WebSocket-Key without adjusting the response, which breaks the check for every client behind them. General handshake debugging continues in debugging WebSocket handshake failures.
Operational checklist #
FAQ #
Is Sec-WebSocket-Key a security feature? #
No. It prevents a non-WebSocket server or cache from being mistaken for a WebSocket endpoint. Anyone can compute the matching accept value, so it provides no authentication or protection against malicious clients.
Why does the handshake use SHA-1 if SHA-1 is broken? #
Because nothing depends on SHA-1 being secure here. It is a fixed transform proving the server implemented the protocol; collision attacks are irrelevant to that purpose.
What causes “Incorrect Sec-WebSocket-Accept header value”? #
The server (or a proxy) computed the accept value incorrectly: usually by base64-decoding the key before hashing, hex-encoding the digest, or a middlebox altering the key header. Recompute the expected value locally and compare.
Can I reuse the same Sec-WebSocket-Key? #
Clients must generate a fresh random key per connection. Servers do not track keys, so reuse is not detected, but it defeats the purpose of the check against cached responses.
Related #
- Debugging WebSocket Handshake Failures — the wider handshake troubleshooting guide.
- WebSocket Subprotocol Negotiation — the other header pair in the handshake.
- Inspecting WebSocket Frames in DevTools and Wireshark — seeing the handshake on the wire.
- WebSocket Handshake over HTTP/2 — where these headers are not used at all.
Back to Protocol Handshake Mechanics.