# 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.

Source: https://howhttpworks.com/guides/websockets-over-http
Last reviewed: 2026-10-05

> **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](https://www.rfc-editor.org/rfc/rfc6455#section-1.3). Messages on this page are examples, not captures from a live server.

```http
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
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

```

[`Upgrade`](https://howhttpworks.com/headers/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`](https://howhttpworks.com/headers/sec-websocket-key). Version `13` means RFC 6455.

To build [`Sec-WebSocket-Accept`](https://howhttpworks.com/headers/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:

```bash
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:

```text
s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
```

The browser checks the whole response, accept value included; a [`101`](https://howhttpworks.com/status-codes/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](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_servers) walks through the switch in detail.

## HTTP/2 and HTTP/3 use extended CONNECT

[RFC 8441 §5](https://www.rfc-editor.org/rfc/rfc8441#section-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:

```text
: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](https://www.rfc-editor.org/rfc/rfc9220#section-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](https://nginx.org/en/docs/http/websocket.html), 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:

```nginx
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`](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#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](https://developers.cloudflare.com/network/websockets/); 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](https://websockets.spec.whatwg.org/#the-websocket-interface) 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](https://websockets.spec.whatwg.org/#opening-handshake) ties into the [Fetch Standard](https://fetch.spec.whatwg.org/#credentials) 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](https://cheatsheetseries.owasp.org/cheatsheets/WebSocket_Security_Cheat_Sheet.html#session-management). 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:

```javascript
// 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](https://cheatsheetseries.owasp.org/cheatsheets/WebSocket_Security_Cheat_Sheet.html#cross-site-websocket-hijacking-cswsh).

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](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_servers#pings_and_pongs_the_heartbeat_of_websockets) and the [WebSockets Standard](https://websockets.spec.whatwg.org/#ping-and-pong-frames).

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](https://www.rfc-editor.org/rfc/rfc6455#section-7.4.1) and the [IANA registry](https://www.iana.org/assignments/websocket/websocket.xhtml#close-code-number):

| Code | Meaning | What to investigate |
| --- | --- | --- |
| `1000` | Normal closure; the connection's purpose is complete. | Whether the application meant to finish. |
| `1001` | An endpoint is going away, such as shutdown or page navigation. | Server lifecycle and navigation. |
| `1006` | Abnormal closure without a received Close frame; **never transmitted** in a Close frame. | Handshake failure, transport loss and proxy logs. |
| `1008` | A received message violates endpoint policy. | Authorization and message validation. |
| `1011` | The 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](https://websockets.spec.whatwg.org/#dom-websocket-close). 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](https://github.com/vi/websocat) installed, connect to the endpoint using an allowed origin:

```bash
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](https://developer.chrome.com/docs/devtools/network/reference#frames) shows each payload with its length, timestamp and control-frame type. Log close events in your app too:

```javascript
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.

## Related

- [Upgrade](https://howhttpworks.com/headers/upgrade), [Sec-WebSocket-Key](https://howhttpworks.com/headers/sec-websocket-key) and [Sec-WebSocket-Accept](https://howhttpworks.com/headers/sec-websocket-accept) for handshake header details.
- [101 Switching Protocols](https://howhttpworks.com/status-codes/101) for the HTTP/1.1 response.
- [SSE vs WebSockets](https://howhttpworks.com/compare/sse-vs-websockets) for choosing a transport.
