How HTTP Works

Guide

WebSockets over HTTP: Handshakes, Proxies and Auth

Trace a WebSocket HTTP handshake, verify Sec-WebSocket-Accept, configure nginx, secure browser authentication, and debug idle disconnects and close codes.

Reviewed 7 min readintermediate14 sourcesTry itMarkdown
On this page

TL;DR: Over HTTP/1.1, a WebSocket starts life as a GET with Upgrade: websocket. If the server answers 101 Switching Protocols with the right Sec-WebSocket-Accept, the same TCP connection switches to WebSocket frames. To make that work in production: forward the upgrade headers through every proxy, authenticate and check Origin before you accept, and send heartbeats more often than your shortest idle timeout. HTTP/2 and HTTP/3 skip the upgrade and use extended CONNECT instead.

The HTTP/1.1 handshake

Here’s the exchange, using the sample nonce and accept value from RFC 6455 §1.3. Messages on this page are examples, not captures from a live server.

GET /chat HTTP/1.1
Host: socket.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: https://app.example.com
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

Upgrade names the protocol the client wants, and Connection: Upgrade says that header is about this connection. The client generates a fresh random 16-byte nonce, Base64-encodes it, and sends it as Sec-WebSocket-Key. Version 13 means RFC 6455.

To build Sec-WebSocket-Accept, the server takes the Base64 text from the header (trimmed, but not decoded), appends 258EAFA5-E914-47DA-95CA-C5AB0DC85B11, SHA-1 hashes the result, and Base64-encodes the digest. You can check it with Node:

node -e 'const {createHash}=require("node:crypto"); const accept=createHash("sha1").update("dGhlIHNhbXBsZSBub25jZQ=="+"258EAFA5-E914-47DA-95CA-C5AB0DC85B11","ascii").digest("base64"); console.log(accept); if(accept!=="s3pPLMBiTxaQ9kYGzzhZRbK+xOo=") process.exit(1)'

We ran it, and it prints the RFC’s sample value:

s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

The browser checks the whole response, accept value included; a 101 status on its own isn’t enough. Once the handshake succeeds, everything on that HTTP/1.1 connection is WebSocket frames carrying text, binary data or control messages. A chat message is a frame, not another HTTP request. And the SHA-1 dance only proves the server understood the handshake. It says nothing about who the user is. MDN’s server guide walks through the switch in detail.

HTTP/2 and HTTP/3 use extended CONNECT

RFC 8441 §5 runs a WebSocket over a single HTTP/2 stream. The server first advertises SETTINGS_ENABLE_CONNECT_PROTOCOL = 1, then the client sends an extended CONNECT. Decoded, the header fields look like this:

:method: CONNECT
:protocol: websocket
:scheme: https
:authority: socket.example.com
:path: /chat
sec-websocket-version: 13
origin: https://app.example.com

A 2xx response such as :status: 200 means success, and WebSocket frames then travel inside DATA frames on that stream. There’s no Connection or Upgrade header and no Sec-WebSocket-Key/Sec-WebSocket-Accept calculation. Other streams on the connection keep carrying ordinary HTTP traffic.

RFC 9220 §3 brings the same extended CONNECT approach to HTTP/3, on a QUIC stream, again gated on SETTINGS_ENABLE_CONNECT_PROTOCOL. Supporting HTTP/2 or HTTP/3 in general is a separate thing from supporting this extension. Check the WebSocket connection itself, at every proxy hop; whatever protocol served your HTML tells you nothing here.

nginx: preserve the upgrade and budget the silence

Upgrade and Connection are hop-by-hop headers, so nginx drops them unless you set them yourself. Following nginx’s WebSocket proxying documentation, this config sets both and talks HTTP/1.1 to the upstream. The map goes in your existing http block and the location inside your server:

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    listen 8080;

    location /chat {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_read_timeout 120s;
    }
}

120s is just an example budget. proxy_read_timeout defaults to 60s, and it times the gap between reads from the upstream, not the whole session. A chatty client won’t reset it, because the timer watches the server-to-nginx direction. Have the upstream send data or protocol Ping frames inside whatever budget you choose.

Cloudflare supports WebSockets on all plans; make sure Network → WebSockets is On for the zone. Its WAF looks at the opening HTTP request, not at the WebSocket messages that follow. Cloudflare also documents that it closes idle connections and terminates connections when it restarts its network code, without giving a numeric idle timeout on that page. Build reconnects into the client: the zone setting allows WebSockets, but connections will still drop.

Authentication: cookies, URL tickets and subprotocols

The browser API is new WebSocket(url, protocols). There’s no option for arbitrary headers, so unlike fetch(), you can’t set Authorization from the constructor.

  • Cookie session: the browser sends the opening handshake with Fetch’s include credentials mode. Which cookies go along is still up to cookie scope and browser policy, so look at the real request’s Cookie header when debugging. Validate the session before upgrading, and close open sockets when the session is revoked. The WebSockets opening algorithm ties into the Fetch Standard for this.
  • Token in the URL: wss://socket.example.com/chat?ticket=... lets the server authenticate before accepting, but query strings end up in access logs, as OWASP points out. Use a short-lived, single-use ticket fetched over an authenticated HTTPS request, redact it in every log layer, and keep reusable bearer tokens out of URLs.
  • The subprotocol trick: offer an application protocol plus a ticket, encoded as a valid protocol token, in the constructor’s second argument. This is an application convention layered on subprotocol negotiation, not a standard authentication scheme.

A private convention might look like this:

// ticket must contain only base64url characters; obtain it over HTTPS first.
const socket = new WebSocket('wss://socket.example.com/chat', [
  'chat.v1',
  `auth.${ticket}`,
]);

On the server, validate the auth. entry, consume the ticket, and select only chat.v1 in Sec-WebSocket-Protocol. The response has to pick one of the offered protocols, and browsers that offered protocols reject a response that picks none. Redact this request header too: the secret left the URL, but it’s now in a header that logs can capture. Document the convention on both client and server so nobody has to guess.

Check Origin before accepting cookies

Any web page can open a WebSocket to your application. If the browser attaches the victim’s session cookie and your server accepts the connection, the attacker’s page now has an authenticated channel. That’s cross-site WebSocket hijacking.

For browser endpoints, compare Origin against an exact allowlist such as https://app.example.com before you send 101. Reject unknown, missing or null origins unless you’ve made a deliberate decision to allow them. Compare whole origins, not substrings: https://app.example.com.attacker.example is a different origin. CORS headers like Access-Control-Allow-Origin don’t apply to WebSockets, so this server-side check is the one that counts.

Non-browser clients can send any Origin they like, so it isn’t authentication. Authenticate those clients separately. Also authorize each operation inside the messages: an accepted socket shouldn’t grant access to every document ID someone sends over it.

Ping, pong and close codes

Protocol Ping (0x9) and Pong (0xA) are control frames. When a peer gets a Ping, it replies with a Pong carrying the same payload, subject to the closing rules. Browser JavaScript has no protocol-level ping(), and sending the text "ping" is just an application message. If the browser needs to start a heartbeat, define one at the application level; otherwise have the server send protocol Pings. See MDN and the WebSockets Standard.

Set the heartbeat interval below the shortest idle timeout on the route. Then track the replies and kill sessions that stop answering. Writing on a timer keeps the connection busy, but only a reply shows the other side is alive.

The meanings below are checked against RFC 6455 §7.4.1 and the IANA registry:

CodeMeaningWhat to investigate
1000Normal closure; the connection’s purpose is complete.Whether the application meant to finish.
1001An endpoint is going away, such as shutdown or page navigation.Server lifecycle and navigation.
1006Abnormal closure without a received Close frame; never transmitted in a Close frame.Handshake failure, transport loss and proxy logs.
1008A received message violates endpoint policy.Authorization and message validation.
1011The server encountered a condition preventing it from fulfilling the request.Server exception logs.

In the browser, socket.close(code) only accepts 1000 or 3000–4999, as the standard specifies. Server libraries can send the other permitted protocol codes, so socket.close(1008) belongs in server code; in browser code it throws.

Debug the handshake separately from the messages

With websocat installed, connect to the endpoint using an allowed origin:

websocat -v --origin https://app.example.com wss://socket.example.com/chat

websocat doesn’t have your browser’s cookies, so if the endpoint needs a session, expect an authentication failure unless you supply one. Keep production credentials out of shell transcripts you share.

In Chrome DevTools, open Network → WS, pick the connection, and look at Headers and then Messages. The Messages tab shows each payload with its length, timestamp and control-frame type. Log close events in your app too:

socket.addEventListener('close', ({ code, reason, wasClean }) => {
  console.log({ code, reason, wasClean });
});

On HTTP/1.1, getting a 200 with HTML back means the upgrade never happened, so check the route and the proxy headers. A 403 means something refused the connection before accepting it: look at authentication, Origin checks and edge rules. If messages flow and then stop, line up the last server message, the heartbeat replies, how long the connection sat idle, and the proxy logs. A 1006 tells you the connection died abnormally. It won’t tell you why.

Frequently asked questions

Does every WebSocket start with a 101 response?

The HTTP/1.1 Upgrade handshake does. HTTP/2 and HTTP/3 use extended CONNECT with a successful 2xx response, so a missing 101 is not itself a failure on those transports.

Is Sec-WebSocket-Key an authentication token?

No. It is a handshake nonce. Anyone can compute the corresponding Sec-WebSocket-Accept; authenticate the user separately.

Why does my nginx WebSocket disconnect after 60 seconds?

The default proxy_read_timeout is 60 seconds between reads from the upstream. If the upstream sends nothing, nginx closes the connection. Configure the timeout deliberately and have the server send heartbeat traffic before it expires.

Can browser JavaScript set an Authorization header on a WebSocket?

The browser WebSocket constructor accepts a URL and optional subprotocols, with no parameter for arbitrary headers. Use an existing cookie session or design an explicit token exchange.

What does WebSocket close code 1006 mean?

It reports an abnormal closure without a received Close frame. It cannot be sent in a Close frame and does not identify which network layer failed.

Sources

  1. MDN: Writing WebSocket serversdeveloper.mozilla.org
  2. RFC 6455: Opening handshake and close codesrfc-editor.org
  3. RFC 8441: WebSockets with HTTP/2rfc-editor.org
  4. RFC 9220: WebSockets with HTTP/3rfc-editor.org
  5. WHATWG: WebSockets Standardwebsockets.spec.whatwg.org
  6. WHATWG: Fetch Standardfetch.spec.whatwg.org
  7. Node.js: Crypto hashing APInodejs.org
  8. nginx: WebSocket proxyingnginx.org
  9. nginx: proxy_read_timeoutnginx.org
  10. Cloudflare: WebSocketsdevelopers.cloudflare.com
  11. OWASP: WebSocket Security Cheat Sheetcheatsheetseries.owasp.org
  12. IANA: WebSocket Close Code Number Registryiana.org
  13. websocat: Usage and command-line optionsgithub.com
  14. Chrome DevTools: Analyze WebSocket messagesdeveloper.chrome.com

Keep going

Browse /search