Real-time apps make CORS weirder than plain old fetch().

A normal API request is easy to reason about: browser sends an Origin, server returns Access-Control-Allow-Origin, done. Real-time stacks like Socket.IO and SignalR add negotiation endpoints, long polling fallbacks, credentials, sticky sessions, and WebSocket upgrades. That combination creates the kind of bug where everything works locally, then production starts throwing “CORS policy blocked” while your websocket dashboard looks perfectly healthy.

I’ve hit this enough times that I now treat real-time CORS as a separate problem, not just “API CORS but more.”

The first thing to understand

CORS applies to the HTTP parts of real-time communication.

That means:

  • negotiation requests
  • polling requests
  • fallback transports
  • preflight OPTIONS requests
  • any auth bootstrap done over HTTP

For pure WebSocket connections, browsers still send an Origin header, but WebSocket is not governed by CORS in the same way as fetch(). You still need to validate origin server-side, but you won’t fix a bad WebSocket origin policy by sprinkling Access-Control-Allow-* headers everywhere.

That’s why Socket.IO and SignalR often fail in confusing ways:

  1. the initial HTTP negotiation is blocked by CORS
  2. the app reports websocket errors
  3. you chase the wrong layer

A quick refresher on real CORS headers

A public API like GitHub often sends:

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, Warning

That’s a good reminder of two things:

  • Access-Control-Allow-Origin: * is fine for public, non-credentialed access
  • Access-Control-Expose-Headers matters if browser code needs to read non-simple response headers

For real-time apps, credentials are common, which changes the rules completely.

Rule #1: credentials and wildcard origins do not mix

If your client sends cookies or auth tied to browser credentials, this is invalid:

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

Browsers reject it.

You must echo or explicitly set a specific allowed origin:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin

That single rule causes a huge percentage of Socket.IO and SignalR production issues.


Socket.IO CORS setup

Socket.IO uses HTTP first, even when the final transport is WebSocket. The browser may hit endpoints like:

  • GET /socket.io/?EIO=4&transport=polling
  • POST /socket.io/?EIO=4&transport=polling
  • then possibly upgrade to WebSocket

So yes, your Socket.IO server needs real CORS configuration.

Basic Node.js server

import express from "express";
import http from "http";
import { Server } from "socket.io";

const app = express();
const server = http.createServer(app);

const allowedOrigins = [
  "https://app.example.com",
  "https://admin.example.com"
];

const io = new Server(server, {
  cors: {
    origin: allowedOrigins,
    methods: ["GET", "POST"],
    credentials: true
  }
});

io.on("connection", (socket) => {
  console.log("connected", socket.id);

  socket.emit("welcome", {
    message: "connected to realtime server"
  });

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

server.listen(3000, () => {
  console.log("Socket.IO server listening on :3000");
});

Browser client

<script type="module">
  import { io } from "https://cdn.socket.io/4.8.1/socket.io.esm.min.js";

  const socket = io("https://realtime.example.com", {
    withCredentials: true
  });

  socket.on("connect", () => {
    console.log("connected", socket.id);
    socket.emit("chat:send", { text: "hello" });
  });

  socket.on("chat:message", (msg) => {
    console.log("message", msg);
  });
</script>

If the page is served from https://app.example.com, the server must allow that exact origin.

Dynamic origin validation

Hardcoding a list is fine until you have staging, preview environments, or tenant subdomains.

const io = new Server(server, {
  cors: {
    origin(origin, callback) {
      const allowed = [
        "https://app.example.com",
        "https://staging.example.com"
      ];

      if (!origin) {
        // non-browser clients or same-origin cases
        return callback(null, true);
      }

      if (allowed.includes(origin) || origin.endsWith(".customers.example.com")) {
        return callback(null, true);
      }

      return callback(new Error("Origin not allowed by CORS"));
    },
    methods: ["GET", "POST"],
    credentials: true
  }
});

Be careful with suffix matching. I’ve seen people write checks that accidentally allow evilcustomers.example.com.attacker.tld. Parse and validate properly if you need pattern matching.

Restricting transports can simplify debugging

When CORS issues get messy, I sometimes force WebSocket only during debugging:

const io = new Server(server, {
  cors: {
    origin: "https://app.example.com",
    credentials: true
  },
  transports: ["websocket"]
});

That removes polling from the equation. It won’t solve origin validation for WebSocket, but it narrows the failure path.

Origin checks for WebSocket upgrades

Socket.IO’s CORS config mainly handles the HTTP transport layer. For stronger control, especially around upgrades, add server-side checks.

io.engine.on("headers", (headers, req) => {
  const origin = req.headers.origin;
  if (origin === "https://app.example.com") {
    return;
  }
});

A better pattern is middleware that rejects unauthorized connections based on origin and auth together:

io.use((socket, next) => {
  const origin = socket.handshake.headers.origin;
  const token = socket.handshake.auth?.token;

  if (origin !== "https://app.example.com") {
    return next(new Error("bad origin"));
  }

  if (token !== "expected-demo-token") {
    return next(new Error("unauthorized"));
  }

  next();
});

CORS is not authentication. I still see teams treating “allowed origin” as if it means “trusted user.” It doesn’t.


SignalR CORS setup

SignalR has the same core problem: the browser starts with HTTP requests before or alongside real-time transport upgrades. If CORS is wrong on the hub route or negotiation endpoint, the whole thing dies.

ASP.NET Core server

using Microsoft.AspNetCore.SignalR;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddCors(options =>
{
    options.AddPolicy("SignalRPolicy", policy =>
    {
        policy.WithOrigins(
                "https://app.example.com",
                "https://admin.example.com"
            )
            .AllowAnyHeader()
            .AllowAnyMethod()
            .AllowCredentials();
    });
});

builder.Services.AddSignalR();

var app = builder.Build();

app.UseRouting();
app.UseCors("SignalRPolicy");

app.MapHub<ChatHub>("/chatHub");

app.Run();

public class ChatHub : Hub
{
    public async Task SendMessage(string user, string message)
    {
        await Clients.All.SendAsync("ReceiveMessage", user, message);
    }
}

The ordering matters. If UseCors() is in the wrong place, you get bizarre negotiation failures.

JavaScript client

<script src="https://cdnjs.cloudflare.com/ajax/libs/microsoft-signalr/8.0.7/signalr.min.js"></script>
<script>
  const connection = new signalR.HubConnectionBuilder()
    .withUrl("https://realtime.example.com/chatHub", {
      withCredentials: true
    })
    .build();

  connection.on("ReceiveMessage", (user, message) => {
    console.log(`${user}: ${message}`);
  });

  async function start() {
    try {
      await connection.start();
      console.log("SignalR connected");
      await connection.invoke("SendMessage", "alice", "hello from client");
    } catch (err) {
      console.error(err);
      setTimeout(start, 3000);
    }
  }

  start();
</script>

If you use cookies for auth, AllowCredentials() is usually required server-side and withCredentials: true client-side.

Common SignalR mistake: allowing any origin with credentials

This is wrong:

policy.AllowAnyOrigin()
      .AllowAnyHeader()
      .AllowAnyMethod()
      .AllowCredentials();

ASP.NET Core blocks this pattern for a reason. A credentialed cross-origin real-time endpoint should never be open to arbitrary origins.

Per-environment origins

This is a practical setup I like:

var allowedOrigins = builder.Configuration
    .GetSection("Cors:AllowedOrigins")
    .Get<string[]>() ?? Array.Empty<string>();

builder.Services.AddCors(options =>
{
    options.AddPolicy("SignalRPolicy", policy =>
    {
        policy.WithOrigins(allowedOrigins)
              .AllowAnyHeader()
              .AllowAnyMethod()
              .AllowCredentials();
    });
});

appsettings.json:

{
  "Cors": {
    "AllowedOrigins": [
      "https://app.example.com",
      "https://staging.example.com"
    ]
  }
}

That beats editing code every time a new frontend environment appears.


Debugging real-time CORS without losing your mind

When a real-time connection fails, inspect the first HTTP requests in browser dev tools.

For Socket.IO, look at the polling or handshake request first.

For SignalR, look at the negotiate request first.

Check:

  • request Origin
  • response Access-Control-Allow-Origin
  • response Access-Control-Allow-Credentials
  • whether an OPTIONS preflight happened
  • whether cookies were sent
  • whether the server returned a redirect

Redirects are a nasty one. If your negotiation endpoint redirects from http to https, or from apex to www, CORS can fail before the app gets anywhere useful.

I also check whether the infrastructure layer is overriding headers:

  • CDN
  • reverse proxy
  • ingress controller
  • API gateway

A lot of “app CORS bugs” are actually proxy config bugs.


Security advice that actually matters

1. Keep origin allowlists tight

If only one frontend should connect, allow one frontend.

Access-Control-Allow-Origin: https://app.example.com

Not this:

Access-Control-Allow-Origin: *

Especially not for authenticated hubs or sockets.

2. Treat CORS and auth as separate controls

CORS decides which browser origins can read responses.

Auth decides who the user is.

You need both.

3. Validate WebSocket origin explicitly

Even if your framework smooths over most of it, validate the Origin header for upgrade requests or handshakes where possible. Browser CORS behavior does not replace server-side trust decisions.

4. Don’t forget other browser protections

CORS is one piece. If you’re tightening browser-side security for real-time apps, you may also care about CSP and related headers. For that, see https://csp-guide.com.


A practical checklist

When Socket.IO or SignalR breaks cross-origin, I run this checklist:

  • Is the frontend origin exactly listed?
  • Are credentials enabled on both client and server if using cookies?
  • Are we incorrectly using * with credentials?
  • Is the failure happening on negotiate or polling, not WebSocket itself?
  • Is middleware order correct?
  • Is a proxy stripping or replacing CORS headers?
  • Are redirects happening before negotiation completes?
  • Are we validating WebSocket origin separately?

If you get those right, most “mysterious” real-time CORS issues stop being mysterious. They become boring config bugs, which is exactly what you want.