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.
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 #
- Confirm the proxy forwards
OriginandX-Forwarded-Proto(curl -i - Reject requests with a missing
Origin - Verify the 403 is returned at
verifyClient - Add a token/cookie auth check inside
connection - Track
ws_handshake_403_totalandws_abnormal_closure_total
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.
Related #
- Security & TLS Configuration — parent guide covering TLS termination, header passthrough, and certificate setup.
- Enforcing Origin and CSRF Checks on WebSockets — combine origin allowlists with token-based CSRF defense at the upgrade.
- Configuring Nginx for WebSocket Upgrades — the proxy directives that keep upgrade and forwarded headers intact.
- Real-Time Protocol Selection & Architecture — how WebSocket security fits alongside protocol choice and handshake mechanics.