Skip to content

WebSockets and CORS: what the browser checks and how to proxy them

CORS does not apply to WebSockets. Learn what the Origin header does, how servers must check it, how to authenticate a socket, and how to proxy one safely.

The ProxifyEdge team 5 min read

If you have ever opened a WebSocket to another domain and wondered why there was no CORS error, you are not imagining it: WebSockets and CORS do not interact the way fetch and CORS do. The browser lets a page open a socket to any origin. Whether that is safe depends entirely on what the server does with the Origin header, and that difference catches out a lot of teams.

This guide covers what the browser actually checks for a WebSocket, why servers must enforce origin rules themselves, how to authenticate a socket when you cannot set headers, and what to look for when proxying one.

Why CORS does not apply to WebSockets

CORS is a set of rules the browser applies to responses that script wants to read. A fetch to another origin is sent, but the response is withheld unless the server’s Access-Control-Allow-Origin permits your origin. If you need a refresher, what CORS is walks through the mechanics.

A WebSocket starts as an HTTP request with an Upgrade: websocket header. If the server agrees, it answers 101 Switching Protocols and the connection becomes a two-way message channel. The browser never runs a CORS check on that handshake:

  • There is no preflight OPTIONS request, whatever subprotocol or messages you send.
  • Access-Control-* response headers are ignored.
  • Once the handshake succeeds, every message the server sends is readable by the page.

The specification made this choice deliberately. Instead of enforcing access itself, the browser sends an Origin header on the handshake and leaves the decision to the server.

GET /ticker HTTP/1.1
Host: stream.example.com
Upgrade: websocket
Connection: Upgrade
Origin: https://app.example.org
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Sec-WebSocket-Protocol: json.v1

The Origin header is the only line of defense

Because the browser enforces nothing, a WebSocket server that ignores Origin will accept a connection from any page on the internet. That matters when the socket relies on cookies. A malicious page can open a socket to your server, the browser attaches the user’s cookies to the handshake, and the attacker’s script reads everything your server pushes. This is known as cross-site WebSocket hijacking, and it is the WebSocket equivalent of a CSRF attack with the response readable.

The defense is simple and must happen on the server: compare the Origin header against an allowlist before accepting the upgrade, and refuse with 403 otherwise.

// Node.js with the "ws" package: refuse unknown origins at the handshake.
import { WebSocketServer } from 'ws';

const ALLOWED = new Set(['https://app.example.org']);

const server = new WebSocketServer({
  port: 8080,
  verifyClient: ({ origin }, done) => {
    if (ALLOWED.has(origin)) return done(true);
    done(false, 403, 'Origin not allowed');
  },
});

Two details are worth getting right:

  • Compare exact origins. https://app.example.org is not https://app.example.org.attacker.net. Use set membership, not startsWith or a loose regular expression.
  • Decide what a missing Origin means. Browsers always send it on a WebSocket handshake. Non-browser clients such as servers and command-line tools may not. Allowing a missing origin is reasonable only if those clients authenticate some other way, because a missing header cannot come from a victim’s browser session.

Authenticating a WebSocket

The browser WebSocket constructor takes a URL and an optional list of subprotocols. It does not let you set request headers, so the usual Authorization: Bearer … header is not available. You have three practical options.

Cookies

If the socket lives on the same site as your app, cookies are sent with the handshake automatically. This is convenient, but it is exactly the case where an origin check is mandatory, because the cookies ride along whichever page opens the socket.

A short-lived token in the URL

Fetch a short-lived ticket from an authenticated HTTP endpoint, then pass it as a query parameter:

const { ticket } = await fetch('/api/socket-ticket', { method: 'POST' }).then((r) => r.json());
const socket = new WebSocket(`wss://stream.example.com/ticker?ticket=${encodeURIComponent(ticket)}`);

Keep the ticket single-use and valid for seconds, not hours. URLs end up in server logs and proxy logs, so a long-lived token in a query string is a credential you will eventually leak.

The subprotocol header

Some servers accept a token through Sec-WebSocket-Protocol, because it is the one header the constructor lets you set. It works, but the server must echo back a subprotocol it actually supports, and mixing credentials with protocol negotiation tends to confuse intermediaries. Prefer a dedicated ticket where you control both ends.

What else a WebSocket server should limit

An accepted socket is a long-lived resource, so limits matter more than they do for a single request:

  • Message size. Set a maximum frame or message size on both directions, so one client cannot exhaust memory with a giant message.
  • Idle time. Close connections that have been silent in both directions for a while, so abandoned tabs do not hold resources forever. Pings that keep a busy connection alive should count as activity.
  • Subprotocols. Forward or accept only the subprotocol you negotiated, rather than copying arbitrary handshake headers.
  • Backpressure. If a reader falls behind, stop reading from the other side instead of buffering without bound.

Proxying WebSockets from the browser

Sometimes the WebSocket you need belongs to someone else: a market data feed, a chat service, a device gateway. You may want to proxy it to add authentication, to keep an upstream credential out of the browser, or to reach an endpoint that only accepts connections from servers. A WebSocket proxy terminates the browser’s socket, opens a second socket to the upstream, and relays messages in both directions.

A good proxy repeats every check described above, because the browser still enforces nothing:

  1. It checks the page’s Origin against the policy for the credential presented, before connecting upstream.
  2. It refuses destinations on private or internal networks, so the proxy cannot be used to reach machines behind it. The SSRF guide explains why this matters for any proxy.
  3. It limits message size and closes idle tunnels.
  4. It forwards only the negotiated subprotocol, not arbitrary headers.

Proxying a WebSocket with ProxifyEdge

ProxifyEdge tunnels WebSockets at /proxy/ws. Because browsers cannot set headers on a socket, the key travels as the key query parameter, and the target goes in url:

const target = 'wss://stream.example.com/ticker';
const socket = new WebSocket(
  `wss://api.proxifyedge.com/proxy/ws?key=pk_your_public_key&url=${encodeURIComponent(target)}`,
);
socket.addEventListener('message', (event) => console.log(event.data));

Before connecting upstream, ProxifyEdge checks the page’s Origin against the key’s policy: a live key only accepts the origins listed for it, the same rule that applies to HTTP requests. Messages are limited to 10 MiB, a tunnel idle in both directions for five minutes is closed, and only the negotiated subprotocol is passed through. The details are in the WebSocket section of the docs, and the same key model is explained in publishable API keys.

Debugging WebSocket connections

When a socket will not open, the browser console usually says little beyond “WebSocket connection failed”. The network panel is more useful:

  • Filter by WS to see the handshake request and its status. A 403 almost always means an origin or authentication check refused you.
  • A 101 followed by an immediate close points at the application layer: look at the close code and reason in the Messages tab.
  • Close code 1006 means the connection dropped without a proper close frame, often a timeout or a proxy in between giving up.

Key takeaways

  • Browsers do not apply CORS to WebSockets; they send Origin and trust the server to decide.
  • Every WebSocket server must check Origin against an exact allowlist, especially when cookies are involved.
  • Authenticate sockets with cookies plus an origin check, or with a short-lived single-use ticket, not a long-lived token in the URL.
  • Limit message size and idle time, and forward only the negotiated subprotocol.
  • A WebSocket proxy must repeat the origin check and refuse internal destinations, because nobody else will.

Are you sure?