CORS and WebSockets

Here's a fact that makes people's eyebrows shoot up when I mention it at work: WebSockets do not use CORS. At all. The entire preflight mechanism, the Access-Control-Allow-Origin headers, the whole song and dance we've been discussing throughout this book — none of it applies to WebSocket connections.

If you just felt a chill run down your spine thinking about the security implications, good. You're paying attention. Let's unpack this.

The WebSocket Handshake

A WebSocket connection starts as an HTTP request that gets "upgraded" to the WebSocket protocol. Here's what the handshake looks like on the wire:

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

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

Notice a few things:

  1. The browser does send an Origin header. This is important.
  2. There is no Access-Control-Allow-Origin in the response.
  3. There is no preflight OPTIONS request.
  4. The server responds with 101 Switching Protocols and that's it — you're on WebSockets now.

You can observe this handshake yourself. Open DevTools, go to the Network tab, filter by "WS", and connect to a WebSocket server. You'll see the initial HTTP request with the upgrade headers and the Origin header right there.

Why WebSockets Bypass CORS

The WebSocket protocol predates the modern CORS specification, and the two were designed by different groups with different security models. The CORS spec (maintained as part of the Fetch Standard) explicitly scopes itself to HTTP requests made via fetch(), XMLHttpRequest, and similar APIs. The WebSocket API is a different beast.

The reasoning, as far as I can reconstruct it from spec discussions, goes something like this:

  1. WebSocket connections are initiated explicitly by JavaScript calling new WebSocket(url). There's no ambient authority problem like there is with cookies being automatically attached to HTTP requests.
  2. The WebSocket handshake includes an Origin header that the server is expected to validate.
  3. The spec authors decided that origin validation for WebSockets should be the server's responsibility, not the browser's.

Whether you agree with this design decision is a separate matter. The practical consequence is that you must implement origin checking yourself on the server side. The browser will not protect you.

The Origin Header in WebSocket Handshakes

Even though the browser doesn't enforce CORS for WebSockets, it does faithfully send the Origin header. This is your lifeline.

When a page at https://myapp.example.com opens a WebSocket:

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

The handshake request will include:

Origin: https://myapp.example.com

When a page at https://evil-site.example.net tries to connect to your WebSocket server:

Origin: https://evil-site.example.net

The browser cannot lie about the Origin header in a WebSocket handshake. It's set by the browser itself, not by JavaScript. Your server can trust it — at least when the request comes from a browser.

Important caveat: Non-browser clients (curl, Postman, custom scripts) can set the Origin header to anything they want. Origin validation protects you against cross-site attacks in browsers, not against determined attackers with custom HTTP clients. But that's true of CORS in general — it's a browser security mechanism, not a server authentication mechanism.

# A non-browser client can fake the origin:
curl -v \
  --include \
  -H "Connection: Upgrade" \
  -H "Upgrade: websocket" \
  -H "Sec-WebSocket-Version: 13" \
  -H "Sec-WebSocket-Key: $(openssl rand -base64 16)" \
  -H "Origin: https://definitely-not-evil.com" \
  https://ws.example.com/chat

Server-Side Origin Validation

Since the browser won't do CORS enforcement for you, you need to validate the Origin header yourself in your WebSocket server. Here's how that looks in a few popular frameworks.

Node.js with the ws Library

const WebSocket = require("ws");

const ALLOWED_ORIGINS = [
  "https://myapp.example.com",
  "https://staging.myapp.example.com"
];

const wss = new WebSocket.Server({
  port: 8080,
  verifyClient: (info, callback) => {
    const origin = info.origin || info.req.headers.origin;

    if (!origin || !ALLOWED_ORIGINS.includes(origin)) {
      console.log(`Rejected WebSocket connection from origin: ${origin}`);
      callback(false, 403, "Forbidden");
      return;
    }

    callback(true);
  }
});

wss.on("connection", (ws, req) => {
  console.log(`Connection from ${req.headers.origin}`);

  ws.on("message", (message) => {
    console.log(`Received: ${message}`);
    ws.send(`Echo: ${message}`);
  });
});

Python with websockets

import asyncio
import websockets

ALLOWED_ORIGINS = {
    "https://myapp.example.com",
    "https://staging.myapp.example.com",
}

async def handler(websocket):
    origin = websocket.origin
    if origin not in ALLOWED_ORIGINS:
        await websocket.close(4003, f"Origin {origin} not allowed")
        return

    async for message in websocket:
        await websocket.send(f"Echo: {message}")

async def main():
    # The `origins` parameter does built-in origin checking:
    async with websockets.serve(
        handler,
        "0.0.0.0",
        8080,
        origins=ALLOWED_ORIGINS
    ):
        await asyncio.Future()  # Run forever

asyncio.run(main())

Go with gorilla/websocket

package main

import (
    "log"
    "net/http"

    "github.com/gorilla/websocket"
)

var allowedOrigins = map[string]bool{
    "https://myapp.example.com":         true,
    "https://staging.myapp.example.com": true,
}

var upgrader = websocket.Upgrader{
    CheckOrigin: func(r *http.Request) bool {
        origin := r.Header.Get("Origin")
        return allowedOrigins[origin]
    },
}

func handleWebSocket(w http.ResponseWriter, r *http.Request) {
    conn, err := upgrader.Upgrade(w, r, nil)
    if err != nil {
        log.Printf("Upgrade failed: %v", err)
        return
    }
    defer conn.Close()

    for {
        messageType, message, err := conn.ReadMessage()
        if err != nil {
            break
        }
        conn.WriteMessage(messageType, message)
    }
}

func main() {
    http.HandleFunc("/ws", handleWebSocket)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

Note: gorilla/websocket's default CheckOrigin rejects all cross-origin requests if you don't set it. Many tutorials tell you to set it to func(r *http.Request) bool { return true } to "fix" WebSocket connection issues. Please don't do that in production. That's the equivalent of setting Access-Control-Allow-Origin: * — except worse, because at least CORS has other protections built in.

ws:// vs wss:// Security Implications

Just like HTTP vs HTTPS, WebSockets have an unencrypted variant (ws://) and a TLS-encrypted variant (wss://):

SchemePort (default)EncryptedMixed content
ws://80NoBlocked from HTTPS pages
wss://443YesAllowed from HTTPS pages

The critical point: modern browsers block ws:// connections from pages served over HTTPS. This is the mixed content policy, and it applies to WebSockets just as it does to HTTP subresources.

// On a page served over https://myapp.example.com:

// This will be BLOCKED (mixed content):
const ws1 = new WebSocket("ws://ws.example.com/chat");
// Error in console: Mixed Content: The page was loaded over HTTPS,
// but attempted to connect to the insecure WebSocket endpoint
// 'ws://ws.example.com/chat'.

// This works:
const ws2 = new WebSocket("wss://ws.example.com/chat");

In development, you might use ws://localhost:8080 and that's fine — browsers typically exempt localhost from mixed content restrictions. But in production, always use wss://.

Also worth noting: because wss:// goes through TLS, the WebSocket handshake is encrypted. This means proxies and firewalls can't inspect or modify the Origin header. Without TLS, a man-in-the-middle could theoretically modify the Origin header during the handshake, bypassing your server-side origin validation.

Socket.IO and Its Own CORS Configuration

If you're using Socket.IO, buckle up, because it has its own CORS configuration that's separate from both your HTTP server's CORS setup and the WebSocket protocol's lack of CORS.

Socket.IO starts with HTTP long-polling and then upgrades to WebSockets. The long-polling phase uses regular HTTP requests, which means CORS applies. When it upgrades to WebSockets, CORS no longer applies. Socket.IO handles both, but you need to configure it properly.

Server-Side (Node.js)

const { Server } = require("socket.io");

// Socket.IO v4 — CORS must be explicitly configured
const io = new Server(3000, {
  cors: {
    origin: ["https://myapp.example.com", "https://staging.myapp.example.com"],
    methods: ["GET", "POST"],
    credentials: true
  }
});

io.on("connection", (socket) => {
  console.log(`Client connected: ${socket.id}`);
  console.log(`Origin: ${socket.handshake.headers.origin}`);

  socket.on("chat message", (msg) => {
    io.emit("chat message", msg);
  });
});

Client-Side

import { io } from "socket.io-client";

const socket = io("https://api.example.com", {
  withCredentials: true,
  // Socket.IO will try WebSocket first (if available), falling back to polling
  transports: ["websocket", "polling"]
});

socket.on("connect", () => {
  console.log("Connected:", socket.id);
});

socket.on("connect_error", (err) => {
  // This might be a CORS error during the polling phase
  console.error("Connection error:", err.message);
});

A common pitfall: you've configured CORS on your Express app using the cors middleware, but Socket.IO has its own CORS configuration. They don't share settings. You need to configure both:

const express = require("express");
const { createServer } = require("http");
const { Server } = require("socket.io");
const cors = require("cors");

const app = express();

// CORS for your REST API endpoints
app.use(cors({
  origin: "https://myapp.example.com",
  credentials: true
}));

const httpServer = createServer(app);

// CORS for Socket.IO (separate configuration!)
const io = new Server(httpServer, {
  cors: {
    origin: "https://myapp.example.com",
    credentials: true
  }
});

// REST endpoint
app.get("/api/messages", (req, res) => {
  res.json({ messages: [] });
});

// WebSocket handler
io.on("connection", (socket) => {
  socket.on("message", (data) => {
    io.emit("message", data);
  });
});

httpServer.listen(3000);

I've lost count of the number of times I've seen someone configure CORS for their Express routes and then wonder why Socket.IO still throws CORS errors. It's always the polling transport. Always.

Common Pattern: REST + WebSockets in the Same App

A typical modern application uses REST APIs for CRUD operations and WebSockets for real-time updates. This means you're dealing with two different security models simultaneously:

REST API (https://api.example.com/messages)
  └── Protected by CORS
  └── Browser enforces Access-Control-Allow-Origin
  └── Preflight for non-simple requests

WebSocket (wss://ws.example.com/live)
  └── NOT protected by CORS
  └── Server must validate Origin header manually
  └── No preflight, ever

Here's what a complete setup might look like:

// Client-side
class MessageService {
  constructor(apiBase, wsBase) {
    this.apiBase = apiBase;
    this.wsBase = wsBase;
    this.ws = null;
    this.listeners = new Set();
  }

  // REST: fetch messages (CORS-protected by browser)
  async getMessages() {
    const response = await fetch(`${this.apiBase}/messages`, {
      credentials: "include"
    });
    return response.json();
  }

  // REST: send a message (CORS-protected by browser)
  async sendMessage(text) {
    const response = await fetch(`${this.apiBase}/messages`, {
      method: "POST",
      credentials: "include",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ text })
    });
    return response.json();
  }

  // WebSocket: real-time updates (NOT CORS-protected)
  connect() {
    this.ws = new WebSocket(`${this.wsBase}/live`);

    this.ws.onmessage = (event) => {
      const data = JSON.parse(event.data);
      this.listeners.forEach(fn => fn(data));
    };

    this.ws.onclose = () => {
      // Reconnect after a delay
      setTimeout(() => this.connect(), 3000);
    };
  }

  onMessage(callback) {
    this.listeners.add(callback);
    return () => this.listeners.delete(callback);
  }
}

const service = new MessageService(
  "https://api.example.com",
  "wss://ws.example.com"
);

On the server side, you need to remember that these two transports have different security requirements:

// Server-side: two security models, one application

// REST: CORS middleware handles origin checking
app.use("/api", cors({
  origin: "https://myapp.example.com",
  credentials: true
}));

// WebSocket: manual origin checking
wss.on("connection", (ws, req) => {
  const origin = req.headers.origin;
  if (origin !== "https://myapp.example.com") {
    ws.close(4003, "Origin not allowed");
    return;
  }

  // Also validate the user's session/token
  const token = new URL(req.url, "https://ws.example.com")
    .searchParams.get("token");
  if (!isValidToken(token)) {
    ws.close(4001, "Unauthorized");
    return;
  }
});

Security: What Happens If You Skip Origin Validation

Let's be concrete about the risk. If your WebSocket server accepts connections from any origin, here's an attack scenario:

  1. User logs into https://yourapp.com and gets a session cookie.
  2. User visits https://evil-site.com (maybe they clicked a link in an email).
  3. evil-site.com runs JavaScript that opens a WebSocket to wss://ws.yourapp.com/live.
  4. The browser sends the Origin: https://evil-site.com header, but your server doesn't check it.
  5. If your WebSocket server uses cookies for authentication (or the connection inherits the user's session), evil-site.com now has a live WebSocket connection to your server, authenticated as the user.
  6. The attacker can now receive real-time data meant for the user, or send messages as the user.

This is essentially a Cross-Site WebSocket Hijacking (CSWSH) attack. It's the WebSocket equivalent of CSRF, and it's entirely preventable by checking the Origin header.

# Demonstrating the attack with curl:
# An attacker's page could do the equivalent of this:
curl -v \
  --include \
  -H "Connection: Upgrade" \
  -H "Upgrade: websocket" \
  -H "Sec-WebSocket-Version: 13" \
  -H "Sec-WebSocket-Key: $(openssl rand -base64 16)" \
  -H "Origin: https://evil-site.com" \
  -H "Cookie: session=stolen-or-browser-attached-cookie" \
  https://ws.yourapp.com/live

If your server responds with 101 Switching Protocols to this request, you have a vulnerability.

Best Practices for WebSocket Security

  1. Always validate the Origin header. Use an allowlist, not a blocklist.
  2. Don't rely solely on cookies for WebSocket authentication. Use a short-lived token passed as a query parameter or in the first message.
  3. Use wss:// in production. Always. No exceptions.
  4. Implement rate limiting on WebSocket connections per IP and per user.
  5. Validate messages. Just because a connection was authenticated at handshake time doesn't mean every message is safe.
// Better authentication pattern: token-based
// Step 1: Get a short-lived WebSocket token via your REST API (CORS-protected)
const response = await fetch("https://api.example.com/ws-token", {
  method: "POST",
  credentials: "include"
});
const { token } = await response.json();

// Step 2: Use the token to connect (token is single-use, short-lived)
const ws = new WebSocket(`wss://ws.example.com/live?token=${token}`);

This way, even if an attacker can initiate a WebSocket connection from an evil page, they can't get a valid token because the REST endpoint that issues tokens is protected by CORS and requires the user's cookies to be sent from an allowed origin.

Summary

FeatureHTTP (fetch/XHR)WebSocket
CORS enforcementBrowser-enforcedNone
Origin header sentYesYes
Origin validationBy browser (CORS)By server (manual)
Preflight requestsYes (for non-simple)Never
Cookie handlingControlled by credentialsSent automatically (same-origin cookies)
Mixed content blockedYes (HTTP from HTTPS page)Yes (ws:// from HTTPS page)

The key takeaway: WebSockets give you more power and more responsibility. The browser sends the Origin header but won't enforce anything based on the response. If you're running a WebSocket server that accepts connections from web browsers, validating the Origin header is not optional — it's the only thing standing between you and cross-site WebSocket hijacking.