Nginx

Nginx WebSockets Connection Failing With 400 Bad Request

A WebSocket connection failing with 400 Bad Request through Nginx almost always means Nginx isn't forwarding the specific headers required for the HTTP-to-WebSocket protocol upgrade handshake — without them, the backend never sees the request as a WebSocket upgrade attempt at all, and rejects it as a malformed regular HTTP request instead.

The Problem

A WebSocket client tries to connect through Nginx acting as a reverse proxy, and the connection fails immediately:

WebSocket connection to 'wss://example.com/socket' failed: 
Error during WebSocket handshake: Unexpected response code: 400

The same WebSocket server works fine when the client connects directly to it, bypassing Nginx, which points clearly at the proxy configuration as the cause rather than the WebSocket server itself.

Why It Happens

A WebSocket connection begins as a normal HTTP request that includes specific headers (Upgrade: websocket and Connection: Upgrade) signaling the server to "upgrade" the connection from plain HTTP into the WebSocket protocol. By default, Nginx's proxy_pass doesn't automatically forward these particular headers to the upstream, since standard HTTP proxying wasn't originally designed with this connection-upgrade pattern in mind. Without them reaching the backend, the backend sees what looks like a regular, incomplete HTTP request rather than a genuine WebSocket upgrade attempt, and rejects it.

The Fix

Explicitly forward the required headers, and use Nginx's special $connection_upgrade variable pattern to correctly pass through the connection upgrade behavior:

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

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

The map block (typically placed once at the http block level, outside any specific server block) handles a subtlety: it sets Connection to upgrade when the client is genuinely requesting a protocol upgrade, but to close otherwise, correctly supporting both regular HTTP requests and WebSocket upgrade requests through the same location block without breaking either.

proxy_http_version 1.1 is also required — WebSocket connections rely on HTTP/1.1 specifically (the older HTTP/1.0, which is Nginx's default for proxied connections unless overridden, doesn't support connection upgrades at all), so this line is a necessary companion to the header forwarding, not optional.

If your WebSocket connections need to stay open for extended periods (common, since WebSockets are often used for long-lived real-time connections), also increase the relevant read timeout, since Nginx's default timeout would otherwise close an idle-but-legitimate long-lived WebSocket connection:

location /socket {
    proxy_pass http://backend;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection $connection_upgrade;
    proxy_read_timeout 3600s;
}

Still Not Working?

If headers are correctly configured but the 400 error persists, check whether a CDN or load balancer in front of Nginx is itself stripping the Upgrade header before the request ever reaches Nginx — many CDNs require WebSocket support to be explicitly enabled in their own configuration or dashboard, separate from anything configured in Nginx, since by default some treat all traffic as standard cacheable HTTP and don't automatically pass through connection-upgrade headers.

It's also worth confirming your WebSocket client is actually connecting to the correct path matching your location block, and using the correct scheme (wss:// for a connection that should be encrypted, matching an HTTPS-terminated Nginx server) — a scheme mismatch, or a path that doesn't match any configured location block with the WebSocket-specific headers included, falls through to a different, non-WebSocket-aware location block that also produces a 400-style rejection, but for a completely different underlying reason than a missing header configuration.

If you're running multiple Nginx locations for different WebSocket endpoints (a chat service, a live-notifications service, etc.), confirm the map block for $connection_upgrade is defined once at the http level and genuinely visible to every relevant server block, rather than accidentally duplicated or scoped incorrectly inside just one server block — a map directive placed inside a server block rather than the top-level http block will fail to parse correctly in standard Nginx configuration, since map is only valid at the http context level.

Finally, test the WebSocket upgrade handshake directly with a raw HTTP request tool to see exactly what headers Nginx forwards versus what the client originally sent, isolating whether the gap is between client-and-Nginx or between Nginx-and-backend:

curl -i -N \
  -H "Connection: Upgrade" \
  -H "Upgrade: websocket" \
  -H "Sec-WebSocket-Version: 13" \
  -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
  http://your-domain.com/socket

Comparing the response here (through Nginx) against a direct request to the backend (bypassing Nginx) confirms exactly which hop in the chain is dropping the required headers.