skip to content

A WebSocket bootstrapped over HTTP/2 sends no `Sec-WebSocket-Key` and gets no `101`, so which handshake fields survive and what signals success?

level: middleimportance: should knowfreq 46%

answer

  1. negotiation survives, proof does not
  2. the digest had a job, now done elsewhere
  3. look for 200, not 101
  4. four fields live, two are gone
  5. lowercase field names on the wire

basics

~10 s

Origin, Sec-WebSocket-Version, Sec-WebSocket-Protocol and Sec-WebSocket-Extensions survive, lowercased as HTTP/2 requires. The Key/Accept digest is gone because the :protocol pseudo-header does its job, and success is a 2xx status - in practice 200.

solid answer

~40 s

The bootstrap keeps the negotiation fields and drops the proof-of-intent ones. `Origin`, `Sec-WebSocket-Version`, `Sec-WebSocket-Protocol` and `Sec-WebSocket-Extensions` all still appear on the extended CONNECT request and mean exactly what they meant in the HTTP/1.1 handshake — written lowercase, because HTTP/2 encodes every field name that way. `Sec-WebSocket-Key` and its `Sec-WebSocket-Accept` answer are not used at all: their job was to prove that a real WebSocket client sent the request and that a real WebSocket server understood it, and `:protocol = websocket` on a method that only an aware server accepts already proves both. Success is a `2xx` on the stream — in practice `:status = 200` — because HTTP/2 has no `101`. An engineer grepping a trace for `101` concludes nothing opened, when in fact everything did.

code

http · 13 lines
http
# request
:method = CONNECT
:protocol = websocket
:scheme = https
:path = /hoist/shaft-3/signals
:authority = hoist-control.example:443
origin = https://console.example
sec-websocket-version = 13
sec-websocket-protocol = hoist.signal.v1

# response
:status = 200
sec-websocket-protocol = hoist.signal.v1

go deeper

for a junior

Remember that over HTTP/2 there is no 101 and no key exchange - a 200 on the stream means the socket is open.

for a middle

Split the fields into the two that proved intent and the four that negotiate, and explain why only the proving pair became unnecessary.

for a senior

Watch for version-specific assumptions baked into tooling and dashboards: anything keyed on 101 quietly reports zero the day a fleet moves to HTTP/2.

for a principal

When a signal changes shape by protocol version, the standard you set is that observability keys on the outcome, not on one version's artifact.

## Two kinds of field, and only one kind survives The HTTP/1.1 handshake carries fields doing two different jobs, and the HTTP/2 bootstrap keeps only one of them. - **Proof fields.** `Sec-WebSocket-Key` (sent by the client) and `Sec-WebSocket-Accept` (returned by the server) are a challenge and its answer. Their purpose was never secrecy — the key travels in clear and the transform is public — it was to prove that a WebSocket-aware client deliberately sent this request and that a WebSocket-aware server deliberately answered it, so that no cache or naive server could be tricked into completing a handshake it did not understand. - **Negotiation fields.** `Origin`, `Sec-WebSocket-Version`, `Sec-WebSocket-Protocol` and `Sec-WebSocket-Extensions` say who is asking, which version of the protocol, and what should be negotiated for the socket. Over HTTP/2 the proof fields are redundant. A `CONNECT` carrying `:protocol = websocket` can only be understood by a server that implements the extension, and a server that does not implement it cannot accidentally complete the exchange — it has no path that leads to a converted stream. The pseudo-header supersedes the digest entirely, so neither `Sec-WebSocket-Key` nor `Sec-WebSocket-Accept` is used. The negotiation fields survive untouched in meaning. ## Lowercase, and why that trips people HTTP/2 carries field names in lowercase. `Sec-WebSocket-Version` goes on the wire as `sec-websocket-version`; `Origin` as `origin`. HTTP/1.1 treated field names case-insensitively, so the mixed-case spellings in `RFC 6455` were always a convention rather than a requirement — but a reader who learned them from a textbook trace and then greps a converted trace for `Sec-WebSocket-Protocol` finds nothing. The values are unchanged. What a negotiated subprotocol name or an extension parameter actually *means* is the same question it was over HTTP/1.1, and is not what changes here. ## What success looks like ```http :status = 200 sec-websocket-protocol = hoist.signal.v1 ``` That is the whole confirmation. The stream is now a tunnel and the WebSocket protocol runs inside it. | | HTTP/1.1 handshake | Extended CONNECT bootstrap | |---|---|---| | Client proof | `Sec-WebSocket-Key` | none - `:protocol` supersedes it | | Server proof | `Sec-WebSocket-Accept` | none | | Version, Origin, subprotocol, extensions | present, mixed case | present, lowercase | | Success status | `101 Switching Protocols` | a `2xx`, in practice `200` | | Field-name casing | case-insensitive | lowercase on the wire | **Say `2xx`, then say `200`.** The specification defines success as a 2xx status on the stream; `200` is what implementations send and what you will see. Promoting that to "it must be exactly 200" is the kind of over-precision an interviewer will push on. ## The failure this causes in practice Someone is debugging a console that will not receive live signals. They take a capture, search it for `101`, find none, and report that the socket never opened. In fact the socket opened on the first attempt, answered `200`, and has been carrying frames for an hour — they were looking for an artifact of a different protocol version. The same reversal happens the other way: a team enables HTTP/2 between two hops, the client silently switches to the bootstrap, and the monitoring that counted `101` responses as "sockets opened" drops to zero. Nothing broke. The metric was version-specific and nobody noticed. The habit worth forming: when you assert anything about a WebSocket handshake, say which HTTP version you are describing. Over HTTP/1.1 it is a `GET`, a key, an accept and a `101`. Over HTTP/2 or HTTP/3 it is a `CONNECT`, a `:protocol`, no digest and a `200`. Both are correct answers to "how does a WebSocket open" and each is wrong about the other.

  • Why is dropping the Key/Accept digest safe here, when RFC 6455 treats it as mandatory?
    The digest proved that both ends were deliberately speaking WebSocket, guarding against a cache or an unaware server completing a handshake by accident. `:protocol = websocket` on an extended CONNECT proves the same thing: only a server that implemented the extension can act on it, and one that did not has no route to a converted stream.
  • A monitoring dashboard counts 101 responses as sockets opened. What happens when a client fleet starts using HTTP/2?
    The count goes to zero while the sockets keep working. The bootstrap answers a 2xx, so a version-specific metric silently stops matching. The fix is to count the thing that is version-independent - established tunnels, or open sockets as the application sees them - not a status code from one HTTP version.

saying these in an interview costs you the question

  • Says Sec-WebSocket-Accept still confirms an HTTP/2 bootstrap.
  • Looks for 101 in an HTTP/2 trace and reports a failure.
  • Thinks all Sec-WebSocket-* fields are dropped over HTTP/2.
  • Believes the Key/Accept digest was a security or authentication check.
  • Expects mixed-case field names on the HTTP/2 wire.