nginx WebSocket 400 Bad Request: How to Fix Missing Upgrade and Connection Headers

# nginx WebSocket 400 Bad Request: How to Fix Missing Upgrade and Connection Headers

`nginx WebSocket 400 Bad Request` usually means the browser sent a WebSocket handshake, Nginx proxied it to the app, and the upstream request arrived without the `Upgrade` and `Connection` headers the backend expected. The fix is to use HTTP/1.1 for that proxied location and pass those headers explicitly. This is the setup that failed for me, and the Nginx config that fixed it.

I hit this while putting a small realtime app behind Nginx. The backend worked locally. The frontend also worked when it connected straight to the app port. Once Nginx sat in front of it, the browser gave me this:

“`text
WebSocket connection to ‘wss://example.com/ws’ failed:
Error during WebSocket handshake: Unexpected response code: 400
“`

The Nginx access log did not give much away:

“`text
“GET /ws HTTP/1.1” 400 157 “-” “Mozilla/5.0 …”
“`

That line made the problem look like an application bug. It was not. The backend rejected the request because the request that reached it was no longer a complete WebSocket upgrade.

## Context: the stack

The setup was ordinary, which made the failure more annoying:

“`text
Browser
-> Cloudflare / TLS
-> Nginx reverse proxy
-> Node.js WebSocket backend on 127.0.0.1:3000
“`

The same issue can show up with Socket.IO, FastAPI, Django Channels, Phoenix, Go `gorilla/websocket`, and most other servers that expect a proper WebSocket handshake. The framework usually is not the issue. The handshake is.

A WebSocket connection begins as an HTTP request that asks the server to switch protocols:

“`http
GET /ws HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: …
Sec-WebSocket-Version: 13
“`

Nginx can proxy that request, but it does not treat every proxied HTTP request as an upgrade request automatically. If the WebSocket path is configured like a normal reverse proxy, the app may still receive `GET /ws`, but it will miss the upgrade signal. Some backends return `400 Bad Request`, some return `426 Upgrade Required`, and some just close the socket.

## The wrong path I tried first

My first mistake was checking only that the route existed.

“`bash
curl -i http://127.0.0.1:3000/ws
“`

That returned a response from the backend, so I assumed the proxy path was fine. It was not a real WebSocket test. Plain `curl` does not send the full browser WebSocket handshake, so it can prove reachability without proving that upgrade handling works.

My next mistake was adding only the `Upgrade` header:

“`nginx
proxy_set_header Upgrade $http_upgrade;
“`

That looks right because almost every WebSocket fix mentions `Upgrade`. The backend also needs the `Connection` header to say the request is asking for a protocol switch. Sending one header without the other is a half-fix: the config looks plausible, but the handshake is still broken.

I also put the WebSocket directives in the wrong `location` block. I had a normal proxy under `/` and the WebSocket endpoint under `/ws`. Nginx matched `/ws`, so headers set only under `/` did nothing. Directives have to be in the block that handles the WebSocket request.

## Forward the WebSocket upgrade headers

For a dedicated WebSocket endpoint, this was the minimal Nginx location that worked:

“`nginx
location /ws {
proxy_pass http://127.0.0.1:3000;

proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection “upgrade”;

proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;

proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
“`

The important lines are these:

`proxy_http_version 1.1` keeps the upstream request on HTTP/1.1. WebSocket upgrade relies on HTTP/1.1 behavior, so the app may never get a valid handshake if the upstream request is handled like ordinary proxy traffic.

`proxy_set_header Upgrade $http_upgrade` passes the browser’s `Upgrade: websocket` header to the backend. Without it, the backend sees a normal HTTP request.

`proxy_set_header Connection “upgrade”` tells the backend this request wants a connection-level protocol upgrade. Without it, many WebSocket libraries treat the request as malformed and return `400 Bad Request`.

The forwarded host and IP headers are not the WebSocket fix itself. I still keep them in the block because real apps usually need correct origin, host, logging, and scheme information.

The long read and send timeouts stop Nginx from closing an idle but still valid WebSocket connection too early. They do not fix the initial `400`, but once the handshake works, they prevent the next failure from showing up a few minutes later.

## Use a map for shared HTTP and WebSocket paths

For apps like Socket.IO, one URL may handle both HTTP polling and WebSocket upgrade requests. In that case, hardcoding `Connection “upgrade”` for every request can be too broad. Use a `map` so Nginx sends `upgrade` only when the client sent an upgrade header.

Put this in the `http` block, not inside `server`:

“`nginx
map $http_upgrade $connection_upgrade {
default upgrade;
” close;
}
“`

Then use it in the WebSocket or Socket.IO location:

“`nginx
server {
listen 443 ssl http2;
server_name example.com;

location /socket.io/ {
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_set_header Host $host;

proxy_buffering off;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
}
“`

I prefer that `map` for mixed traffic because it avoids telling the backend that ordinary HTTP requests are upgrades. With Socket.IO, this was the change that made the transport upgrade stop failing intermittently.

One small detail: `listen 443 ssl http2;` is fine for the browser-facing side, but the upstream WebSocket proxy still needs `proxy_http_version 1.1`. Do not remove that line just because the public site supports HTTP/2.

## Reload Nginx safely

Before reloading, test the config:

“`bash
sudo nginx -t
“`

Expected output:

“`text
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
“`

Then reload:

“`bash
sudo systemctl reload nginx
“`

If you use containerized Nginx, the same idea applies:

“`bash
docker exec nginx nginx -t
docker exec nginx nginx -s reload
“`

Do not restart the backend first unless you have evidence that the backend is the problem. I wasted time doing that. The backend was fine; the proxy was stripping the handshake headers.

## Verify the fix

The browser should stop showing this:

“`text
Error during WebSocket handshake: Unexpected response code: 400
“`

For a command-line check, use a real WebSocket client. I usually use `wscat` because it is quick:

“`bash
npx wscat -c wss://example.com/ws
“`

A working connection looks like this:

“`text
Connected (press CTRL+C to quit)
>
“`

If your endpoint expects a specific subprotocol, token, or path, include it exactly as your frontend does. A successful TCP connection by itself is not enough. You want the WebSocket handshake to complete through the same public URL that failed in the browser.

You can also watch the Nginx access log while testing:

“`bash
sudo tail -f /var/log/nginx/access.log /var/log/nginx/error.log
“`

Before the fix, I saw repeated `400` responses on the WebSocket path. After the fix, the request no longer appeared as a short failed HTTP request because the connection stayed open. That missing fast `400` is a useful clue, but the WebSocket client output is better proof.

One more check helped: dump the request headers at the backend for one test run. I did not leave that logging enabled, but seeing the upstream request removed the guesswork. Before the Nginx fix, my app route was reached without the expected upgrade pair. After the fix, the backend saw the same intent the browser sent: `Upgrade: websocket` plus `Connection: upgrade`. At that point, it stopped looking like a mysterious WebSocket bug and started looking like the proxy-header problem it was.

## Edge cases that still break WebSocket proxying

– If your WebSocket path does not match the Nginx location, the request may fall through to a normal proxy block. For example, a browser request to `/api/ws` will not hit `location /ws`. Check the exact URL in DevTools.

– If a CDN or load balancer sits in front of Nginx, make sure that layer supports WebSockets and is not stripping upgrade headers. The Nginx config can be correct and still fail if the request never reaches it as an upgrade request.

– If the backend validates `Origin`, fixed proxy headers may still leave you with `400` or `403`. Check the backend logs for origin rejection. That is a different bug: the handshake reached your app, and your app decided it did not trust the browser origin.

– Socket.IO is not plain WebSocket, so test it with a Socket.IO client or the browser app, not only a raw WebSocket client. Socket.IO has its own path, transport negotiation, and query parameters.

The useful mental model is simple: Nginx is not only forwarding bytes after the connection opens. It first has to pass the initial HTTP upgrade request correctly. If that request loses `Upgrade` or `Connection`, the backend never enters WebSocket mode.

## Minimal fix

“`nginx
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection “upgrade”;
“`

Leave a Comment