Reverse proxy

WebSockets through a reverse proxy

The RFC 6455 handshake, why proxies strip Upgrade and Connection, config for nginx, HAProxy, Caddy, Traefik and Envoy, and the timeouts that kill sockets.

· 11 min read · How we verify this

Key points

  • A proxy must be told to forward Upgrade and Connection, because RFC 9110 classes them as hop-by-hop headers that a proxy removes by default.
  • nginx needs the Upgrade header forwarded plus a map for Connection (and proxy_http_version 1.1 on builds before 1.29.7); a static Connection: upgrade breaks every non-WebSocket request through the same location.
  • The most common WebSocket bug is not the handshake, it is an idle timeout: nginx proxy_read_timeout defaults to 60s and closes a silent socket.
  • Application-level ping/pong is the only portable fix, because every hop in the chain has its own independent idle timer.

A WebSocket is an ordinary HTTP/1.1 request that asks the server to switch protocols, so a reverse proxy only needs to do two things: speak HTTP/1.1 upstream, and forward the Upgrade and Connection headers instead of stripping them. Once the server answers 101 Switching Protocols the proxy stops parsing HTTP and becomes a byte pipe in both directions. Everything that goes wrong afterwards is a timeout, because a proxy cannot tell an idle tunnel from a dead one.

The handshake on the wire#

RFC 6455 defines the opening handshake as a normal GET with four required request headers. This is the exact byte sequence a browser sends:

http
GET /socket HTTP/1.1
Host: app.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: https://app.example.com

Sec-WebSocket-Key is 16 random bytes, base64 encoded, generated fresh per connection. The server proves it understood the WebSocket protocol (rather than blindly echoing an upgrade) by concatenating that literal base64 string with the fixed GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11, taking the SHA-1 of the result, and base64 encoding the 20 byte digest:

text
"dGhlIHNhbXBsZSBub25jZQ==" + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
SHA-1  -> b3 7a 4f 2c c0 62 4f 16 90 f6 46 06 cf 38 59 45 b2 be c4 ea
base64 -> s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

That value is the one in RFC 6455's own example, and it is a useful test vector: if your proxy or your server library produces anything else for that key, the client will abort before a single frame is sent.

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

The GUID is not a secret and adds no security. Its only job is to make the response impossible to produce by accident, so a server that merely reflects headers cannot be mistaken for a WebSocket endpoint.

Why proxies drop Upgrade and Connection by default#

Connection and everything it names are connection-specific header fields. RFC 9110 section 7.6.1 (previously RFC 7230 section 6.1) requires an intermediary to remove them before forwarding, because they describe the single TCP hop they arrived on, not the end-to-end message. Upgrade is listed in Connection: Upgrade, so a spec-compliant proxy strips both.

That is correct behaviour, and it is why WebSockets need explicit configuration: the proxy is not broken, you are asking it to make a deliberate exception, which both RFCs permit.

The symptom when you forget is that the backend receives a plain GET with no Upgrade header, treats it as a normal request, and returns 400 Bad Request (most WebSocket libraries) or 426 Upgrade Required. The browser console shows Error during WebSocket handshake: Unexpected response code: 400, and nothing in the proxy log looks wrong, because the proxy did proxy a request and did get a response.

Configuration by proxy#

nginx#

nginx defaulted proxy_http_version to 1.0 before 1.29.7, and HTTP/1.0 has no upgrade mechanism, so on those builds this line is mandatory even before the headers. Newer builds default to 1.1, but setting it explicitly keeps the configuration portable.

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

server {
    location /socket {
        proxy_pass http://app_backend;
        proxy_http_version 1.1;
        proxy_set_header Upgrade    $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header Host       $host;

        proxy_read_timeout 3600s;
        proxy_send_timeout 3600s;
    }
}

The map is the part people copy without understanding. You cannot write proxy_set_header Connection "upgrade" unconditionally, because that location also serves ordinary requests: XHR polls, the initial page load, health probes. Sending Connection: upgrade on a request with no Upgrade header tells the upstream to close the connection after the response, which silently disables upstream keep-alive and, on strict servers, produces a 400. The map makes the header conditional: upgrade when the client asked for an upgrade, close otherwise. If you are using an upstream keep-alive pool, note that the two features are in tension; see keep-alive and upstream connection pooling for how nginx reuses connections and nginx proxy_pass and the trailing slash for how the location interacts with the upstream URI.

HAProxy#

HAProxy in mode http handles Upgrade natively with no directives at all. It detects the 101, stops HTTP processing and switches the stream to tunnel mode. The only thing you must add is the tunnel timeout.

haproxy
defaults
    mode http
    timeout connect 5s
    timeout client  30s
    timeout server  30s
    timeout tunnel  1h

backend app_backend
    server app1 10.0.1.10:8080 check

timeout tunnel governs inactivity once a connection has been upgraded. If it is unset, HAProxy falls back to the client and server timeouts, which is exactly the 30s guillotine you did not want.

Caddy#

Caddy v2's reverse_proxy supports WebSockets with no configuration and no timeout on the proxied connection by default.

text
app.example.com {
    reverse_proxy 10.0.1.10:8080
}

This is the least surprising default of the five, and it is worth knowing when you are bisecting a problem: put Caddy in front of the backend for one test and you have eliminated the proxy layer as a suspect. The trade-off is that a socket leaking file descriptors is never reaped by the proxy either.

Traefik#

Traefik forwards upgrades automatically for any HTTP router; there is no per-router WebSocket flag. The knobs that matter are on the entry point (transport.respondingTimeouts) and on the service transport (forwardingTimeouts). Defaults differ between v2 and v3, so check the version you are running rather than copying a snippet. The idleTimeout on an entry point defaults to 180s.

Envoy#

Envoy requires upgrades to be enabled explicitly, per route or per HTTP connection manager, via upgrade_configs.

yaml
route_config:
  virtual_hosts:
  - name: app
    domains: ["*"]
    routes:
    - match: { prefix: "/socket" }
      route:
        cluster: app_backend
        timeout: 0s
        upgrade_configs:
        - upgrade_type: websocket
          enabled: true

timeout: 0s is not optional. Envoy's per-route timeout defaults to 15s and applies to the upgraded stream, so a WebSocket through a default Envoy route dies about fifteen seconds in. You will usually also need to raise or disable stream_idle_timeout on the connection manager, which defaults to 5 minutes.

The timeout table#

This is the table to screenshot. Every row is an independent timer, and the shortest one in the chain wins.

ProxyTimer that kills an idle WebSocketDefaultWhat to set
nginxproxy_read_timeout (also proxy_send_timeout)60s3600s, or leave at 60s and ping every 30s
HAProxytimeout tunnel, falling back to timeout client / timeout serverunset (falls back)timeout tunnel 1h
Caddy v2none applied to the proxied stream by defaultno limitusually nothing
Traefikentry point respondingTimeouts and service forwardingTimeouts; idleTimeout 180sversion dependentverify per version, raise or zero
Envoyroute timeout and HCM stream_idle_timeout15s route, 300s stream idletimeout: 0s, raise stream_idle_timeout
AWS ALBconnection idle timeout60sraise, or ping under 60s

Raising timeouts is a patch, not a fix. Every additional hop (CDN, corporate proxy, carrier NAT, service mesh sidecar) has its own idle timer you do not control. The correct answer is application-level keepalive: send an RFC 6455 ping frame (opcode 0x9) every 20 to 30 seconds and expect a pong (0xA). Real bytes on the wire reset every idle timer in the path at once, and give you liveness detection that TCP alone does not. Model the whole chain as a ladder rather than a single number, as in timeout budgets across a proxy chain, and check the ordering with the timeout ladder checker.

Buffering and masking#

After the 101, response buffering is no longer in play: the proxy is copying bytes, not parsing a body, so nginx's proxy_buffering has no effect on an established WebSocket. It very much does affect Server-Sent Events and chunked streaming responses, which are the two things people reach for when WebSockets are blocked, so if you fell back to SSE and it stopped streaming, read proxy buffering and streaming responses.

Masking is the one framing rule a proxy operator should know. RFC 6455 requires every client-to-server frame to be XOR-masked with a fresh 32-bit key, and requires the server to fail the connection if it receives an unmasked frame. Server-to-client frames must not be masked. The reason is proxies: masking exists specifically so that an intercepting HTTP proxy on the path cannot be fooled into treating attacker-chosen bytes inside a WebSocket stream as the start of a new HTTP request and poisoning its cache. It is a defence against exactly the class of confusion covered in HTTP request smuggling and proxy desync.

Failure modes#

400 Bad Request or 426 Upgrade Required at handshake time. The Upgrade header did not reach the backend. Confirm by logging the request headers at the origin. In nginx the cause is a missing proxy_set_header Upgrade, or, on a build older than 1.29.7, proxy_http_version left at 1.0, in which case you may instead see a plain 200 with the page HTML where you expected a 101.

200 OK instead of 101. The backend answered the GET as a normal route. Either the path did not match the WebSocket handler, or the upgrade headers were stripped. Reproduce without a browser:

bash
curl -i -N -H "Connection: Upgrade" -H "Upgrade: websocket" \
  -H "Sec-WebSocket-Version: 13" \
  -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
  https://app.example.com/socket

A correct chain returns 101 and the Sec-WebSocket-Accept value shown earlier. Run it against the origin first, then through each hop, and the broken hop is the first one that stops returning 101.

Close at exactly 60 seconds, code 1006. An idle timer, not your code. See the table. Code 1006 means the connection dropped without a close frame, which is the signature of a middlebox rather than a peer.

Works locally, fails behind the CDN. Some CDN configurations do not proxy upgrades on all plans or all path patterns, and WAF rules frequently reject the handshake because it is an unusual GET. Check whether the 101 ever appears in the CDN's own logs. If the CDN returns its own error page, the origin never saw the request.

Intermittent failures behind a load balancer with multiple proxy instances. A WebSocket is a single long-lived TCP connection, so it is pinned to whichever backend accepted it. Rolling a deployment kills every socket on the instance being replaced. Clients need reconnect with jittered backoff, or you get a thundering herd; the same reconnect storm interacts badly with health checks that mark a recovering instance up before it can absorb the load.

WebSockets over HTTP/2 and HTTP/3#

Plain RFC 6455 cannot run over HTTP/2. The handshake is a connection-level protocol switch, and HTTP/2 has no Upgrade header and no concept of switching the whole connection: it multiplexes independent streams. RFC 8441 fixes this with extended CONNECT. The server advertises SETTINGS_ENABLE_CONNECT_PROTOCOL (setting 0x8, value 1); the client then opens a single stream with :method = CONNECT and a :protocol = websocket pseudo-header, plus :scheme and :path, and the WebSocket lives inside that one stream. RFC 9220 carries the same mechanism to HTTP/3.

The practical consequence is a support matrix, not a preference. If any hop in the chain terminates HTTP/2 and does not implement RFC 8441, the browser silently falls back to an HTTP/1.1 connection for the WebSocket, which costs you an extra connection but works. If a hop advertises HTTP/2 only, and does not implement extended CONNECT, the WebSocket fails. Envoy supports extended CONNECT and can bridge between HTTP/1.1 upgrades and HTTP/2 extended CONNECT. Check your own versions before relying on it, and read HTTP/2 and HTTP/3 through proxies for how ALPN negotiation at each hop decides which protocol you actually get.

Frequently asked questions#

Why do I need proxy_http_version 1.1 for WebSockets in nginx?#

Because nginx sent HTTP/1.0 to upstreams by default until 1.29.7, and HTTP/1.0 has no protocol upgrade mechanism. On those builds the backend sees an HTTP/1.0 request, cannot return 101 Switching Protocols, and the handshake fails no matter what headers you set. From 1.29.7 the default is 1.1, so the directive is only insurance, but the Upgrade and Connection headers are still required.

What does the nginx Connection upgrade map actually do?#

It sets Connection: upgrade only when the client sent an Upgrade header, and Connection: close otherwise. A hardcoded Connection: upgrade would be applied to every ordinary request through the same location, which disables upstream keep-alive and can make strict backends return 400.

Why does my WebSocket disconnect after 60 seconds?#

An idle timeout somewhere in the path fired. nginx proxy_read_timeout and the AWS ALB idle timeout both default to 60 seconds. Raising them helps, but the durable fix is sending WebSocket ping frames every 20 to 30 seconds so the connection is never idle.

Do HAProxy and Caddy need special WebSocket configuration?#

No. HAProxy in mode http and Caddy v2's reverse_proxy both forward upgrades automatically. HAProxy still needs timeout tunnel set to something generous, otherwise it applies the ordinary client and server timeouts to the tunnel.

Can WebSockets run over HTTP/2?#

Only with RFC 8441 extended CONNECT, which every hop that terminates HTTP/2 must support. Without it, browsers open a separate HTTP/1.1 connection for the WebSocket. RFC 9220 defines the equivalent for HTTP/3.

Why must WebSocket clients mask their frames?#

RFC 6455 requires client-to-server masking so that an attacker cannot place chosen bytes on the wire that an intercepting HTTP proxy might parse as a new HTTP request. It is a cache-poisoning defence aimed at middleboxes, not a confidentiality mechanism, and it adds nothing when the connection is already TLS protected end to end.

Does proxy_buffering affect WebSockets?#

No. Once the 101 response passes through, nginx is copying bytes in tunnel mode and body buffering does not apply. Buffering does affect Server-Sent Events and other chunked streaming responses over normal HTTP.

How do I test a WebSocket handshake without a browser?#

Send the handshake with curl -i -N and the four required headers, including a fixed Sec-WebSocket-Key. A working path returns 101 Switching Protocols with a matching Sec-WebSocket-Accept. Repeat hop by hop from the origin outwards to find which layer stops returning 101.

Primary sources#

Every normative claim on this page is checked against the specification or the vendor documentation listed here. Where behaviour is version dependent, the version is named in the text.

  1. RFC 6455 The WebSocket Protocol
  2. RFC 9110 HTTP Semantics (connection-specific header fields)
  3. RFC 8441 Bootstrapping WebSockets with HTTP/2
  4. RFC 9220 Bootstrapping WebSockets with HTTP/3
  5. nginx WebSocket proxying
  6. nginx ngx_http_proxy_module
  7. HAProxy configuration manual (timeout tunnel)
  8. Envoy upgrade support

Found something wrong, or behaviour that differs on your version? Report it with the version number and a primary source. Anything substantive is fixed in the page and logged on the corrections page. See editorial standards for how pages are researched, sourced and reviewed.

More in reverse proxy configuration#