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
OPTIONSrequests - 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:
- the initial HTTP negotiation is blocked by CORS
- the app reports websocket errors
- 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 accessAccess-Control-Expose-Headersmatters 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=pollingPOST /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
OPTIONSpreflight 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.