Every WebSocket connection begins life as an HTTP request. For a few hundred bytes the client and server speak HTTP, agree to stop, and from the next byte onwards speak a different protocol on the same connection. Almost every WebSocket bug that shows up as a connection that never opens is a handshake bug: a header a proxy dropped, a version the server refuses, an Origin check that is too strict, or one that does not exist.

This article walks the handshake as defined by RFC 6455, header by header, then implements and tests a server-side validator and a client-side probe in Python, covers the Origin check that protects you from cross-site WebSocket hijacking, and shows how the same handshake is expressed over HTTP/2 and HTTP/3. What happens after the handshake is covered in the frame format deep dive.

Advertisement

What the handshake is for, and what it is not

The handshake has three jobs. It lets WebSockets share ports 80 and 443 and existing HTTP infrastructure, because it is a valid HTTP request. It negotiates options: subprotocol, extensions and protocol version. And it proves, through the key and accept exchange, that the server actually understood a WebSocket request, so that neither a plain HTTP server nor a caching proxy can accidentally answer it and leave the client reading garbage.

It is not authentication and it is not encryption. The key is not a secret and the accept value can be computed by anyone; confidentiality comes from TLS (wss://), and identity comes from cookies, tokens or tickets, discussed in WebSocket authentication patterns.

The HTTP/1.1 opening handshake, and where each check happensClientbrowser or libraryProxy or LBmust forward UpgradeServervalidates, answers 101GET /ws, Upgrade, Key, Version 13, Originsame request, hop-by-hop headers re-addedServer checksGET, Host, tokens, key = 16 bytesversion 13, Origin allowed, pick protocol101, Accept = b64(SHA1(key + GUID))101 relayed, tunnel opensClient checks101, tokens, Accept, offersAfter the empty line, both sides speak WebSocket frames on the same TCP connection.
One HTTP request, one 101 response, then frames. Both ends validate, and every intermediary must forward the upgrade.

The client request, header by header

Here is the sample request from RFC 6455, with the same key, which lets you check any implementation against the RFC's published answer.

GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Origin: http://example.com
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
HeaderRuleWhy it exists
Request lineMethod GET, HTTP/1.1 or laterAny HTTP server can parse it; a body is never sent
HostRequiredVirtual hosting and routing, as in any HTTP/1.1 request
UpgradeContains the token websocket, case-insensitiveNames the protocol to switch to
ConnectionContains the token Upgrade; may list others, such as keep-alive, UpgradeMarks Upgrade as hop-by-hop; a proxy must not forward it blindly
Sec-WebSocket-KeyBase64 of 16 random bytes, fresh per connection, so always 24 charactersInput to the accept proof
Sec-WebSocket-VersionMust be 13The only version standardised by RFC 6455
OriginSent by browsersLets the server refuse pages from other sites
Sec-WebSocket-ProtocolOptional, comma-separated, in client preference orderApplication protocol negotiation; see subprotocols
Sec-WebSocket-ExtensionsOptional, such as permessage-deflateFrame-level extensions; see permessage-deflate

The Sec- prefix matters in browsers: scripts cannot set headers with that prefix, so a page cannot forge a WebSocket handshake through fetch or XMLHttpRequest. Only the browser's WebSocket implementation writes them.

Advertisement

The response and the Sec-WebSocket-Accept computation

A server that accepts answers with status 101 and its own headers, then an empty line:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat

The accept value is computed in three steps. Take the key exactly as sent, as a string; do not base64-decode it. Append the fixed GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11. Take the SHA-1 of the result, which is 20 bytes, and base64-encode it, which gives 28 characters. For the RFC key the concatenation is dGhlIHNhbXBsZSBub25jZQ==258EAFA5-E914-47DA-95CA-C5AB0DC85B11 and the answer is s3pPLMBiTxaQ9kYGzzhZRbK+xOo=, which the code below reproduces.

SHA-1's weakness against collision attacks does not matter here: the value is not a security credential, only proof that the server read the request as WebSocket. The GUID is chosen so that no non-WebSocket server would plausibly produce the right value by accident.

import base64, hashlib

GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"

def accept_for(key: str) -> str:
    digest = hashlib.sha1((key + GUID).encode("ascii")).digest()
    return base64.b64encode(digest).decode("ascii")

assert accept_for("dGhlIHNhbXBsZSBub25jZQ==") == "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="

Server-side validation, with the right status codes

Most frameworks do this for you, but you need to know what they check when you debug them or write a gateway. The routine below takes the raw request head and returns the response bytes. It was run against the RFC sample and a set of broken variants before being published here.

ALLOWED_ORIGINS = {"https://app.example.com"}
SUBPROTOCOLS = ["chat.v2", "chat.v1"]          # server preference order
MAX_HEAD = 8192

def tokens(value: str) -> list[str]:
    return [t.strip().lower() for t in value.split(",") if t.strip()]

def reply(status: str, extra: dict | None = None) -> bytes:
    lines = [f"HTTP/1.1 {status}"] + [f"{k}: {v}" for k, v in (extra or {}).items()]
    if not status.startswith("101"):
        lines += ["Content-Length: 0", "Connection: close"]
    return ("\r\n".join(lines) + "\r\n\r\n").encode("latin-1")

def handshake(head: bytes) -> tuple[bytes, bool]:
    if len(head) > MAX_HEAD:
        return reply("431 Request Header Fields Too Large"), False
    lines = head.decode("latin-1").split("\r\n")
    try:
        method, _target, version = lines[0].split(" ")
    except ValueError:
        return reply("400 Bad Request"), False
    h: dict[str, str] = {}
    for line in lines[1:]:
        if not line:
            break
        name, sep, value = line.partition(":")
        if not sep:
            return reply("400 Bad Request"), False
        name = name.strip().lower()
        h[name] = f"{h[name]}, {value.strip()}" if name in h else value.strip()

    if method != "GET" or version != "HTTP/1.1" or "host" not in h:
        return reply("400 Bad Request"), False
    if "websocket" not in tokens(h.get("upgrade", "")):
        return reply("426 Upgrade Required", {"Upgrade": "websocket"}), False
    if "upgrade" not in tokens(h.get("connection", "")):
        return reply("400 Bad Request"), False
    if h.get("sec-websocket-version") != "13":
        return reply("426 Upgrade Required", {"Sec-WebSocket-Version": "13"}), False
    key = h.get("sec-websocket-key", "")
    try:
        if len(base64.b64decode(key, validate=True)) != 16:
            raise ValueError
    except ValueError:
        return reply("400 Bad Request"), False
    origin = h.get("origin")
    if origin is not None and origin not in ALLOWED_ORIGINS:
        return reply("403 Forbidden"), False

    extra = {"Upgrade": "websocket", "Connection": "Upgrade",
             "Sec-WebSocket-Accept": accept_for(key)}
    offered = [t.strip() for t in h.get("sec-websocket-protocol", "").split(",") if t.strip()]
    chosen = next((p for p in SUBPROTOCOLS if p in offered), None)
    if chosen:
        extra["Sec-WebSocket-Protocol"] = chosen
    return reply("101 Switching Protocols", extra), True

Three details are easy to get wrong. Connection is a token list, so an equality test against Upgrade rejects browsers that send keep-alive, Upgrade. An unsupported version gets 426 with Sec-WebSocket-Version: 13 so the client knows what to retry with. And the subprotocol header is only sent when the server picked one of the client's offers; inventing one makes a conforming client fail the connection. The routine has no extension support, so it never sends Sec-WebSocket-Extensions, which is always a valid answer.

StatusMeaning in this handshake
101Accepted; the connection now carries frames
400Malformed: wrong method, missing Host, bad key, missing Connection token
403Well-formed but refused, typically an Origin not on the allowlist
426Not a WebSocket request at an upgrade-only endpoint, or an unsupported version
401 or 302Application-level: not authenticated; browsers will not follow a redirect into a socket

What the client checks

RFC 6455 makes the client the final judge. A conforming client fails the connection unless the status is 101, Upgrade contains websocket, Connection contains Upgrade, the accept value matches its own computation, and any subprotocol or extension in the response was one it offered. In a browser all of this happens inside the WebSocket object, and a failed handshake surfaces only as an error event followed by a close event with code 1006. The page never sees the HTTP status, deliberately, so a script cannot use WebSockets to probe internal hosts. That is why server-side logging of rejected handshakes, with the reason, is essential: the client cannot tell you.

Origin checks and cross-site WebSocket hijacking

The same-origin policy that protects fetch does not stop a page from opening a WebSocket to any host, and the browser attaches that host's cookies to the handshake. If your server authenticates WebSockets by cookie and ignores Origin, any website a logged-in user visits can open an authenticated socket in their name and read the replies. This is cross-site WebSocket hijacking, a variant of CSRF that also leaks data.

  • Compare Origin exactly against an allowlist of scheme, host and port. Do not use substring or suffix matching, which https://app.example.com.evil.net defeats.
  • Reject a missing Origin on endpoints meant only for browsers; allow it on endpoints for server-to-server clients that authenticate by token.
  • Remember that non-browser clients can send any Origin. The check protects browser users from other sites; it does not authenticate anyone.
  • Prefer short-lived tickets or tokens over ambient cookies so a hijacked handshake carries no credential.

Through proxies and load balancers

Upgrade and Connection are hop-by-hop headers: a compliant proxy strips them unless it is configured to handle upgrades. The result is that the backend sees a plain GET and answers 200 or 426, and the client fails. In nginx the fix is the familiar trio of proxy_http_version 1.1, proxy_set_header Upgrade $http_upgrade and proxy_set_header Connection "upgrade". Idle timeouts, connection draining and per-product settings are a large topic of their own, covered in WebSockets through load balancers and the wider WebSocket architecture guide.

HTTP/2 and HTTP/3: extended CONNECT

HTTP/2 has no Upgrade mechanism and multiplexes many streams on one connection, so RFC 6455's handshake cannot be used there. RFC 8441 adds an extended CONNECT: the server advertises SETTINGS_ENABLE_CONNECT_PROTOCOL (setting code 0x8) with value 1, and the client then opens a stream with :method CONNECT, :protocol websocket, :scheme, :path and :authority. The server accepts with :status 200, not 101. RFC 9220 applies the same mechanism to HTTP/3.

HTTP/1.1 (RFC 6455)HTTP/2 and HTTP/3 (RFC 8441, RFC 9220)
RequestGET with Upgrade and ConnectionExtended CONNECT with :protocol websocket
Success status101200
Key and AcceptRequiredNot used; :protocol replaces them
Origin, Version, Protocol, ExtensionsUsedUsed, as in RFC 6455
Connection costOne TCP (and TLS) connection per socketOne stream on a shared connection
CloseClose frame, then TCP closeClose frame, then the stream ends

Operationally, check what your edge actually does before relying on it. Many deployments terminate HTTP/2 at the load balancer and speak HTTP/1.1 upgrades to the backend, so your server code still sees the classic handshake. Clients fall back to HTTP/1.1 when the setting is absent.

Worked example: diagnosing a socket that never opens

Users report that the chat never connects in production, while it works locally. The browser shows only code 1006. The fastest move is to speak the handshake yourself from inside the network, at each hop, with a probe that applies the client's checks and reports which one failed.

import base64, os, socket

def probe(host, port, path="/ws", origin=None, protocols=None, timeout=5.0):
    key = base64.b64encode(os.urandom(16)).decode("ascii")
    req = [f"GET {path} HTTP/1.1", f"Host: {host}:{port}", "Upgrade: websocket",
           "Connection: Upgrade", f"Sec-WebSocket-Key: {key}", "Sec-WebSocket-Version: 13"]
    if origin:
        req.append(f"Origin: {origin}")
    if protocols:
        req.append(f"Sec-WebSocket-Protocol: {protocols}")
    with socket.create_connection((host, port), timeout=timeout) as s:
        s.sendall(("\r\n".join(req) + "\r\n\r\n").encode("latin-1"))
        buf = b""
        while b"\r\n\r\n" not in buf and len(buf) < 16384:
            chunk = s.recv(4096)
            if not chunk:
                break
            buf += chunk
    status, *rest = buf.split(b"\r\n\r\n", 1)[0].decode("latin-1").split("\r\n")
    h = {k.strip().lower(): v.strip() for k, _, v in (l.partition(":") for l in rest)}
    if not status.startswith("HTTP/1.1 101"):
        return f"FAIL status: {status}"
    if h.get("upgrade", "").lower() != "websocket" or "upgrade" not in h.get("connection", "").lower():
        return "FAIL Upgrade/Connection headers missing"
    if h.get("sec-websocket-accept") != accept_for(key):
        return "FAIL Sec-WebSocket-Accept mismatch"
    return f"OK protocol={h.get('sec-websocket-protocol', '-')}"

Run it against the application server directly, then the internal load balancer, then the edge (for wss:// wrap the socket with ssl). In our scenario the app answers OK protocol=chat.v2 with origin="https://app.example.com", protocols="chat.v1, chat.v2", the internal load balancer answers the same, and the edge returns FAIL status: HTTP/1.1 426 Upgrade Required: the edge proxy was dropping the hop-by-hop headers, so the backend saw a plain GET. With the proxy fixed, a second probe with the production Origin returns 403, revealing the second bug: the allowlist held the staging hostname. Neither was visible from the browser.

Failure modes

  • Proxy strips Upgrade and Connection: backend answers 200 or 426. Configure upgrade forwarding at every hop.
  • Exact-match test on Connection: works in one browser, fails in another. Parse tokens.
  • Origin allowlist missing a hostname or scheme: 403 after a domain move. Generate it from the same config as your public URLs.
  • No Origin check with cookie auth: cross-site WebSocket hijacking.
  • Server echoes a subprotocol the client did not offer: conforming clients fail the connection.
  • Authentication redirect (302) on the socket path: browsers fail it as 1006; return 401 or 403 instead and redirect in the page.
  • Accept computed from the decoded key: every handshake fails with an accept mismatch.
  • Handshake floods: each upgrade can be expensive if it triggers auth lookups. Cap header size, rate-limit by client, and do cheap checks before expensive ones.

What to do next

  1. Run the RFC sample key through your stack's accept function and confirm s3pPLMBiTxaQ9kYGzzhZRbK+xOo=.
  2. Log every rejected handshake with its status and reason; the browser will never tell you.
  3. Add an exact-match Origin allowlist to every cookie-authenticated WebSocket endpoint and a test that a foreign Origin gets 403.
  4. Probe each hop (app, internal balancer, edge) with the probe script and keep it as a synthetic check.
  5. Confirm your server only echoes subprotocols and extensions the client offered.
  6. Find out whether your edge speaks RFC 8441 to clients and what it speaks to your backend.
Key takeaway: The WebSocket handshake is one HTTP GET carrying Upgrade, Connection, a random key and version 13, answered by 101 with Sec-WebSocket-Accept, the base64 SHA-1 of the key plus a fixed GUID. It proves the server understood the request; it does not authenticate or encrypt. Parse Connection as a token list, return 400, 403 or 426 deliberately, check Origin exactly because cookies ride along, forward hop-by-hop headers at every proxy, and log rejections server-side because browsers report only code 1006. Over HTTP/2 and HTTP/3 the same negotiation becomes an extended CONNECT answered with 200.