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.

Deriving Sec-WebSocket-Accept The accept value is derived by appending a fixed GUID to the base64 key string without decoding it, hashing with SHA-1, and base64-encoding the digest; the RFC example key produces s3pPLMBiTxaQ9kYGzzhZRbK+xOo=. Deriving Sec-WebSocket-Accept Client nonce 16 random bytes, base64: dGhlIHNhbXBsZSBub25jZQ== Sec-WebSocket-Key Concatenate GUID key string + 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 no decoding SHA-1 hash the ASCII string, 20-byte digest not for security Base64 s3pPLMBiTxaQ9kYGzzhZRbK+xOo= Sec-WebSocket-Accept The most common bug is decoding the key first — the GUID is appended to the base64 text itself
Four fixed steps; any deviation produces an 'Incorrect Sec-WebSocket-Accept' error.

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.

Handshake header checks Which side checks each handshake header and what security value it has: key and accept prevent protocol confusion only, version is a compatibility check, while Origin and cookies or tokens provide the actual security. Handshake header checks Client checks Server checks Security value Sec-WebSocket-Key 16-byte nonce none Sec-WebSocket-Accept must match anti-confusion only Sec-WebSocket-Version must be 13 none Origin sent by browser your allowlist CSWSH defence Cookie / token sent by client your auth authentication Only the rows you implement yourself provide security
The protocol's checks prevent confusion; your checks prevent attacks.

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.

The opening handshake, header by header The client sends a GET with Upgrade, a random key, version 13 and Origin; the server computes the accept value and returns 101 with it; the client compares it with its own expected value and fails the connection on mismatch. The opening handshake, header by header Client Server GET /ws Upgrade: websocket Sec-WebSocket-Key: <16-byte nonce> Sec-WebSocket-Version: 13, Origin accept = b64(sha1(key + GUID)) 101 + Sec-WebSocket-Accept compare with expected, else fail After a matching 101, both sides switch to WebSocket framing on the same TCP connection
One nonce, one fixed transform, one comparison.

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.

Back to Protocol Handshake Mechanics.