Why CORS doesn't apply to WebSockets
You open a WebSocket to a different origin, the browser console shows a failed handshake, and the fix that works for every other cross-origin request — adding Access-Control-Allow-Origin on the server — changes nothing. That's because the WebSocket handshake was never covered by CORS in the first place. This post covers what happens during that handshake, what gates it instead, and how to restrict it yourself.
What happens during the handshake
A WebSocket connection starts as a single HTTP request: a GET with an Upgrade: websocket header, a Sec-WebSocket-Key, and a Sec-WebSocket-Version. The browser also sends an Origin header, the same way it does on any other request. If the server responds 101 Switching Protocols, the TCP connection stays open and switches to the WebSocket framing protocol.
What's missing from that list is a preflight. The websocket.org guide to CORS and WebSockets lays out the mechanics: the browser never sends an OPTIONS request ahead of the handshake, and it never inspects the 101 response for Access-Control-* headers the way it would inspect a fetch response. The handshake either succeeds or it doesn't; there's no browser-side check in between.
Why Access-Control-Allow-Origin does nothing here
Preflight and Access-Control-Allow-Origin are part of the Fetch/CORS machinery: they govern whether a browser lets your JavaScript read a cross-origin HTTP response. WebSocket is a different protocol with its own handshake, defined by RFC 6455, and that spec never adopted the CORS model. The Origin header is sent so the server can make its own decision — the spec puts the enforcement entirely on the server side, not the browser's. Setting Access-Control-Allow-Origin on an HTTP endpoint on the same host doesn't touch the WebSocket upgrade path at all; they're handled by different code, checked by different rules, and in most frameworks, different middleware.
If you're seeing a rejected handshake and calling it a "CORS error," it's almost always one of two things instead: the server (or a reverse proxy in front of it) is refusing the Upgrade explicitly, or a load balancer is stripping the Upgrade/Connection headers before the request reaches your app.
The mixed-content rule that does apply
One browser restriction genuinely does block some cross-origin WebSocket connections: mixed content. A page served over https:// cannot open a plain ws:// connection, full stop, regardless of which host it's targeting — the browser blocks it as insecure content before any handshake is attempted. The fix is to serve the WebSocket endpoint over wss://; once that's true, the connection can be cross-origin without the browser objecting, because, again, cross-origin WebSocket requests aren't something the browser restricts on its own.
Validating the origin yourself
Since the browser won't enforce an allowlist for you, the server has to check Origin during the upgrade, before it accepts the connection. Using the ws package in Node with a custom upgrade handler:
import { createServer } from 'node:http';
import { WebSocketServer } from 'ws';
const ALLOWED_ORIGINS = new Set([
'https://app.example.com',
'http://localhost:5173',
]);
const server = createServer();
const wss = new WebSocketServer({ noServer: true });
server.on('upgrade', (req, socket, head) => {
const origin = req.headers.origin;
if (!origin || !ALLOWED_ORIGINS.has(origin)) {
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req);
});
});
wss.on('connection', (ws) => {
ws.on('message', (data) => ws.send(data));
});
server.listen(8080);
Match the origin exactly, the same way you would for an HTTP CORS allowlist: parse it and compare scheme, host, and port, not a substring check. You can test the rejection path with a raw handshake request, no browser needed:
curl -i -N \
-H "Connection: Upgrade" \
-H "Upgrade: websocket" \
-H "Origin: https://evil.example" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
-H "Sec-WebSocket-Version: 13" \
https://your-ws-server.example.com/socket
A correctly guarded server returns 403 here instead of 101 Switching Protocols.
Cross-site WebSocket hijacking
Skipping the origin check is a bigger risk than it looks like on first read, for the same reason a missing CORS check on a credentialed request is dangerous: the browser attaches cookies to a WebSocket handshake the same way it attaches them to any other same-site request, with no opt-in required. If your server authenticates WebSocket clients purely off a session cookie and never checks Origin, any site the victim has open can silently open a WebSocket to your server from their browser, authenticated as them. This class of attack has a name, cross-site WebSocket hijacking, and it's the WebSocket analogue of CSRF rather than anything CORS-shaped. The mitigation is the same origin check shown above, or a token that the attacker's page can't obtain, not a fetch-style credentials rule. See CORS with cookies and credentials for how cookie-based cross-origin auth is supposed to work on the HTTP side.
Does a CORS proxy help here?
No. corsproxy.dev's /proxy endpoint accepts GET and POST over plain HTTP; it doesn't negotiate a WebSocket upgrade, and neither does the open-source relay we publish on GitHub. If a browser's new WebSocket(...) call is failing, routing it through either one isn't an option, because there's no HTTP response for a proxy to add a header to in the first place. The fix has to happen on the WebSocket server itself: serve it over wss://, make sure nothing upstream strips the Upgrade header, and check Origin the way this post describes. For the cases a proxy does solve, see when not to use a CORS proxy.