If you’ve worked with fetch() long enough, CORS feels familiar: preflights, Access-Control-Allow-Origin, blocked responses, weird credentials rules. Then you open a WebSocket from a browser and things get weird fast.

You expect CORS to kick in. Usually it doesn’t.

That surprises a lot of people because WebSockets start as an HTTP request. But the browser does not apply the normal CORS enforcement model to a WebSocket upgrade the same way it does for fetch() or XHR. Instead, browsers send an Origin header during the handshake, and the server is expected to decide whether to accept the connection.

That distinction matters a lot.

The short version

For browser WebSocket connections:

  • The browser sends an HTTP Upgrade: websocket request
  • It includes an Origin header
  • There is no normal CORS preflight
  • Access-Control-Allow-Origin is generally not how access is granted
  • The server must validate the Origin itself before accepting the upgrade

So if you’re adding CORS middleware and assuming your WebSocket endpoint is protected, that’s usually wrong.

Why WebSockets are different

A normal cross-origin fetch() is governed by the Fetch/CORS model. The browser checks response headers like:

  • Access-Control-Allow-Origin
  • Access-Control-Allow-Credentials
  • Access-Control-Expose-Headers

For example, api.github.com returns:

access-control-allow-origin: *
access-control-expose-headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset

That’s classic CORS behavior for HTTP APIs. The browser reads those headers and decides whether frontend JavaScript can access the response.

WebSockets don’t work like that. The browser performs an HTTP handshake, but after the server replies with 101 Switching Protocols, the connection is no longer normal HTTP traffic. CORS response headers aren’t the control point.

Instead, the browser provides the server with the page’s origin, and the server decides whether that origin is allowed to establish a socket.

What the browser actually sends

Here’s a typical browser WebSocket handshake:

GET /chat HTTP/1.1
Host: ws.example.com
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Origin: https://app.example.com

The key line is:

Origin: https://app.example.com

That’s the browser telling the server: “This WebSocket was initiated by a page from this origin.”

If the server accepts it, the response looks something like this:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

Notice what’s missing: no Access-Control-Allow-Origin.

You can send it if you want, but it’s not what browsers use to approve the socket.

So is CORS irrelevant for WebSockets?

Not quite. The same-origin policy concern still exists. A page from https://evil.example can try to open a socket to wss://api.example.com unless your server rejects it.

What’s different is the enforcement mechanism.

For normal HTTP APIs, the browser blocks JavaScript from reading disallowed cross-origin responses.

For WebSockets, the browser allows the connection attempt and includes Origin; your server must inspect it and decide whether to continue.

That means WebSocket origin validation is more like CSRF protection than classic CORS middleware.

Bad server setup

Here’s a common mistake in Node.js using the ws library:

import http from 'node:http';
import { WebSocketServer } from 'ws';

const server = http.createServer();
const wss = new WebSocketServer({ server });

wss.on('connection', (ws, req) => {
  console.log('WebSocket connected from origin:', req.headers.origin);

  ws.send('connected');
});

server.listen(8080);

This accepts connections from anywhere. If your app relies on cookies for authentication, that can become a real problem. A malicious site may be able to trigger a browser-authenticated socket connection unless you explicitly block untrusted origins.

Better: validate the Origin header

Use the HTTP upgrade event so you can reject bad origins before the WebSocket is established.

import http from 'node:http';
import { WebSocketServer } from 'ws';

const allowedOrigins = new Set([
  'https://app.example.com',
  'https://admin.example.com'
]);

const server = http.createServer();
const wss = new WebSocketServer({ noServer: true });

server.on('upgrade', (req, socket, head) => {
  const origin = req.headers.origin;

  if (!origin || !allowedOrigins.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, req) => {
  ws.send(`connected from ${req.headers.origin}`);
});

server.listen(8080);

That’s the baseline. If you run browser-facing WebSockets, do this.

Credentials and cookies

This is where people get burned.

Browsers can include cookies during the WebSocket handshake, depending on cookie settings and site context. If your app uses session cookies, a cross-origin WebSocket request might carry them.

So if you only authenticate based on cookies and don’t validate Origin, another site may be able to open a socket as the victim.

That’s why I treat WebSocket origin checks as mandatory whenever cookie auth is involved.

A safer pattern is:

  • validate Origin
  • require an explicit auth token
  • avoid relying purely on ambient cookies

Example with a token in the query string:

server.on('upgrade', (req, socket, head) => {
  const origin = req.headers.origin;
  const url = new URL(req.url, 'http://localhost');

  if (!origin || !allowedOrigins.has(origin)) {
    socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
    socket.destroy();
    return;
  }

  const token = url.searchParams.get('token');
  if (token !== process.env.WS_TOKEN) {
    socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
    socket.destroy();
    return;
  }

  wss.handleUpgrade(req, socket, head, (ws) => {
    wss.emit('connection', ws, req);
  });
});

Query-string tokens are easy to demo, though in production I usually prefer a short-lived token negotiated over HTTPS first.

What about preflight requests?

A browser does not normally send an OPTIONS preflight before a WebSocket handshake. So this won’t help:

app.use((req, res, next) => {
  res.setHeader('Access-Control-Allow-Origin', 'https://app.example.com');
  res.setHeader('Access-Control-Allow-Credentials', 'true');
  next();
});

That middleware may be correct for your REST API and still do nothing useful for your WebSocket endpoint.

If your security model depends on those headers alone, your socket server is probably under-protected.

Browser example

Client-side WebSocket code is simple:

const ws = new WebSocket('wss://ws.example.com/chat');

ws.addEventListener('open', () => {
  console.log('connected');
  ws.send(JSON.stringify({ type: 'ping' }));
});

ws.addEventListener('message', (event) => {
  console.log('message:', event.data);
});

ws.addEventListener('close', () => {
  console.log('closed');
});

The browser automatically attaches the correct Origin header based on the page’s origin. You can’t spoof that header from normal frontend JavaScript, which is exactly why servers should trust it as a browser-origin signal.

That said, non-browser clients can send any Origin they want. Don’t confuse origin validation with full authentication.

Reverse proxies can interfere

If you terminate WebSockets behind Nginx or another proxy, make sure the upgrade request reaches the app correctly. A minimal Nginx config looks like this:

location /chat {
    proxy_pass http://localhost: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;
}

I’ve seen setups where the proxy forwards the upgrade but drops Origin, which breaks validation logic in annoying ways.

Common misconceptions

“I set Access-Control-Allow-Origin, so my WebSocket is protected”

Probably not. That header matters for CORS-controlled HTTP responses, not as the primary browser gate for WebSocket upgrades.

“WebSockets ignore origin security entirely”

Also wrong. Browsers send Origin, and you’re expected to enforce policy server-side.

“If it works in Postman, the browser should behave the same”

Nope. Browser behavior is special here. Non-browser clients are not constrained the same way and can forge headers freely.

“SameSite cookies solve everything”

They help, but I wouldn’t treat them as a replacement for origin checks. Cookie policy, browser differences, and deployment quirks are not where I want to place all my trust.

Practical rules I use

If I’m reviewing a WebSocket service, I want to see:

  1. Explicit Origin allowlist
  2. Authentication beyond cookies alone
  3. TLS with wss://
  4. Proxy config that preserves upgrade headers
  5. No assumption that standard CORS middleware covers sockets

If you’re also hardening the rest of your app with security headers beyond CORS, https://csp-guide.com is worth keeping around for CSP-specific work.

The mental model that sticks

Here’s the easiest way to remember it:

  • fetch() and XHR: browser enforces CORS using Access-Control-* response headers
  • WebSockets: browser sends Origin; server must decide whether to accept the upgrade

That’s the whole game.

If you remember only one thing, make it this: WebSocket security is not “set CORS headers and move on.” Check Origin, authenticate properly, and reject upgrades you don’t trust.

For the protocol details, the official references are the MDN WebSocket API docs and the WebSockets specification: