Handling cross-origin WebSocket connections #

Your browser console shows WebSocket connection to 'wss://api.example.com/ws' failed: Error during WebSocket handshake: Unexpected response code: 403, and the Network tab confirms an HTTP 403 (or 400) on the GET /ws request. The same client works from localhost but breaks the moment the frontend is served from a different origin than the API. You reached for CORS headers, added Access-Control-Allow-Origin, and nothing changed — because WebSocket upgrades do not use the CORS response system at all. This page explains why cross-origin upgrades get rejected, and how to validate the Origin header correctly without leaving the socket open to cross-site hijacking.

Root cause #

A browser opening a WebSocket sends a plain HTTP GET with Upgrade: websocket. Critically, there is no CORS preflight — the browser never issues an OPTIONS request, and it never reads an Access-Control-Allow-Origin response header to gate the connection. The same-origin policy that protects fetch() simply does not apply to the WebSocket constructor; a page on any origin may attempt a handshake to any server. The only cross-origin signal the server receives is the Origin request header, which the browser sets automatically and a script cannot forge. Validating that header is therefore the server’s sole responsibility, and skipping it leaves you open to Cross-Site WebSocket Hijacking (CSWSH), where a malicious page rides the victim’s ambient cookies into an authenticated socket.

Two distinct failures produce the 403 you are seeing. The first is over-validation: a framework or copied middleware rejects any Origin that is not exactly the API’s own host, so a legitimately different frontend origin is refused. The second — and far more common in production — is Origin mutation across the proxy hop. Reverse proxies such as Nginx, HAProxy, and AWS ALB terminate TLS at the edge and forward the request upstream over plain HTTP. During that hop the Origin header is frequently dropped, or the backend compares the client’s https://app.example.com against a request that now looks like http://, and strict equality fails. Missing X-Forwarded-Proto makes this worse: the backend has no way to know the client connected over TLS, so it reconstructs the wrong scheme. Getting the proxy layer right is part of a sound Security & TLS Configuration and is a prerequisite for any meaningful origin check.

Origin validation across the proxy hop Browser sends Origin over TLS; proxy terminates TLS and forwards upstream; backend validates the forwarded Origin and allows or rejects the upgrade. Browser Origin: app.example Proxy TLS terminate Backend verifyClient wss:// (TLS) http + Origin allow 101 / reject 403 No CORS preflight — server checks Origin directly

Resolution #

The fix has two halves that must agree: the proxy forwards the real Origin and scheme, and the backend validates against an explicit allowlist. First, ensure Nginx preserves the headers across the upgrade — proxy_set_header Origin $http_origin; and proxy_set_header X-Forwarded-Proto $scheme; are the load-bearing lines. Then validate in verifyClient, which runs during the handshake and can refuse the upgrade before any frame is exchanged:

import { WebSocketServer, type VerifyClientCallbackAsync } from "ws";
import { IncomingMessage } from "node:http";

// Exact-match allowlist. Never use "*": WebSockets ignore the CORS
// Access-Control-Allow-Origin header, so a wildcard means "no check at all".
const ALLOWED_ORIGINS = new Set([
"https://app.example.com",
"https://staging.app.example.com",
]);

function isOriginAllowed(req: IncomingMessage): boolean {
const origin = req.headers.origin;
// No Origin header => not a browser request (curl, native client, or a
// cross-site <img>-style probe). Reject; trusted non-browser clients should
// authenticate with a token instead, not rely on Origin.
if (!origin) return false;

// The proxy terminated TLS and forwarded over http, so the Origin's own
// scheme is reliable but the *connection* scheme comes from the forwarded
// header. Normalize so https client traffic is judged against https rules.
const proto = (req.headers["x-forwarded-proto"] as string) ?? "http";
const normalized = origin.replace(/^https?:\/\//, `${proto}://`);
return ALLOWED_ORIGINS.has(normalized);
}

const verifyClient: VerifyClientCallbackAsync = (info, done) => {
if (!isOriginAllowed(info.req)) {
// Fail at the handshake with 403 — the browser surfaces a clean error and
// no socket is ever established, closing the CSWSH window entirely.
done(false, 403, "Cross-origin WebSocket handshake rejected");
return;
}
done(true);
};

const wss = new WebSocketServer({ port: 8080, verifyClient });

wss.on("connection", (ws, req) => {
// Origin is necessary but not sufficient: still authenticate the session
// (cookie + token) here, because Origin alone does not prove identity.
ws.on("error", (err) => console.error("ws error:", err.message));
ws.on("message", (data) => ws.send(data)); // echo placeholder
});

Pair this with the proxy block so the header actually arrives intact:

location /ws {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header Origin $http_origin; # forward the client's real Origin
proxy_set_header X-Forwarded-Proto $scheme; # let the backend rebuild https://
proxy_read_timeout 86400s; # keep long-lived sockets alive
}

For subdomain fleets, swap the Set for a compiled RegExp such as /^https:\/\/([a-z0-9-]+\.)?example\.com$/, but anchor both ends (^$) so example.com.evil.test cannot slip through. Origin checking stops cross-site connection attempts, but it does not authenticate the user; treat it as one layer and combine it with token validation, covered in depth under enforcing Origin and CSRF checks on WebSockets.

Operational checklist #

It is worth being precise about what the browser does and does not do for you here, because the intuition carried over from fetch is wrong in one decisive way.

fetch versus a WebSocket upgrade, cross-origin A comparison showing that a WebSocket upgrade sends no preflight and its response is always readable cross-origin, so enforcement is entirely the server's responsibility. fetch versus a WebSocket upgrade, cross-origin fetch() WebSocket Preflight sent yes never Origin header sent sent Response readable only if CORS allows always Enforced by the browser your server The last row is the whole point: nobody enforces the origin unless your upgrade handler does
The browser sends the Origin and then gets out of the way. Everything after that is yours.
Where the check has to happen The origin is checked in the upgrade handler before the socket is created, so a rejected request never becomes a connection and never consumes a file descriptor. Where the check has to happen Page Upgrade handler Socket GET /ws, Origin: https://app.example exact match against allow-list handleUpgrade — socket created Origin: https://app.example.evil.com 403 before any socket exists Rejecting before handleUpgrade means an attacker cannot exhaust descriptors by opening connections you will close later
Check before the socket exists, and match exactly — a suffix comparison accepts app.example.evil.com.

FAQ #

Why doesn’t Access-Control-Allow-Origin fix my WebSocket 403? #

Because the WebSocket handshake does not participate in CORS. The browser sends no preflight and never reads Access-Control-Allow-Origin to decide whether to open the socket. Returning that header on the upgrade response has no effect. The server must inspect the Origin request header itself and accept or reject the upgrade based on its own allowlist.

Does origin validation work behind AWS ALB? #

Yes, but the ALB must forward the Origin and X-Forwarded-Proto headers, which it does by default for HTTP/HTTPS listeners. The common failure is comparing a forwarded http://-scheme origin against an https:// allowlist; normalize using X-Forwarded-Proto as shown above. Sticky sessions and the upgrade itself are orthogonal — see the broader Security & TLS Configuration guidance for the full edge setup.

Is checking the Origin header enough to stop attacks? #

No. Origin defeats Cross-Site WebSocket Hijacking from a browser, because a script cannot forge that header. It does nothing against a non-browser client (curl, a server-side bot) that simply omits or fakes the header outside a browser sandbox. Always layer per-connection authentication — a signed token validated at the upgrade — on top of origin checking.

What does an empty or null Origin mean? #

A missing Origin usually means the request did not come from a browser fetch context: native mobile clients, server-to-server calls, or some proxies that strip it. Origin: null can appear from sandboxed iframes or file:// pages. Treat both as “not a trusted browser origin” and require explicit token auth rather than allowing them through.

What changes for Socket.IO vs raw ws? #

Socket.IO wraps the same upgrade, so the Origin header still arrives identically. The difference is the API: configure cors: { origin: [...] } in the Socket.IO server options, which gates the underlying engine handshake. The raw ws verifyClient approach shown here is lower-level and applies whenever you use ws directly or behind frameworks that expose the upgrade request.

Can I use a wildcard origin allow-list? #

Only with exact-suffix matching against a controlled parent domain, and even then be careful: a naive endsWith('.example.com') check accepts app.example.com.evil.com. Parse the origin with the URL API, compare the host exactly against an explicit list, and treat anything unparseable as a rejection. A regex written by hand is the usual source of this bug.

Should the origin check replace authentication? #

No, and treating it as security rather than as a browser-scoped defence is the mistake worth avoiding. Any non-browser client can send whatever Origin it likes, or none at all, so the check protects your users from a malicious page and does nothing against a scripted client. It is one layer; the token check in validating JWT on the WebSocket upgrade is the one that establishes identity.

Back to WebSocket Security & TLS Configuration