Fix: Socket.IO CORS Error — Cross-Origin Connection Blocked
Part of: JavaScript & TypeScript Errors
Quick Answer
How to fix Socket.IO CORS errors — server-side CORS configuration, credential handling, polling vs WebSocket transport, proxy setup, and common connection failures.
The Error
A Socket.IO client fails to connect with a CORS error:
Access to XMLHttpRequest at 'http://localhost:3001/socket.io/?...' from origin 'http://localhost:3000'
has been blocked by CORS policy: Response to preflight request doesn't pass access control check:
No 'Access-Control-Allow-Origin' header is present on the requested resource.Or the WebSocket upgrade fails after the polling phase connects:
WebSocket connection to 'ws://localhost:3001/socket.io/?...' failed:
Error during WebSocket handshake: Unexpected response code: 400Or a CORS error only occurs with credentials:
Access to fetch at 'http://api.example.com/socket.io/' from origin 'http://app.example.com'
has been blocked by CORS policy: The value of the 'Access-Control-Allow-Origin' header
in the response must not be the wildcard '*' when the request's credentials mode is 'include'.Why This Happens
Socket.IO uses HTTP polling as its initial transport before upgrading to WebSocket. The polling phase makes regular HTTP requests (including a preflight OPTIONS request), which are subject to CORS browser security policy. This is the part that trips people up: even though you think of Socket.IO as a WebSocket library, the connection starts as plain HTTP. The browser enforces CORS on those initial polling requests the same way it would on any fetch or XHR call.
The confusion compounds because Socket.IO v2 allowed all cross-origin connections by default. When v3 shipped, the team reversed that decision and locked CORS down. Projects that upgraded from v2 to v3 or v4 suddenly saw CORS errors on connections that had worked for years, because the default behavior flipped from “allow everything” to “allow nothing.”
Key causes:
- No CORS configuration on the Socket.IO server — the default Socket.IO server in v3+ rejects all cross-origin connections.
- Wildcard origin with credentials —
origin: '*'blocks credential-bearing requests. Browsers refuse to send cookies or auth headers to a wildcard CORS endpoint. - Origin array doesn’t include the requesting origin — if the client’s origin isn’t in the allowed list, the server rejects the handshake.
- Proxy strips CORS headers — a reverse proxy (nginx, Cloudflare) may forward requests to Socket.IO but strip or override the CORS headers.
- Client and server Socket.IO version mismatch — Socket.IO v2 client can’t connect to v3/v4 server and vice versa without compatibility mode.
Diagnostic Timeline
When a Socket.IO connection fails with a CORS error, the first instinct is usually to add cors: { origin: '*' } to the server. That can mask the real issue. Walk through these steps instead.
Minute 0 — Read the browser error. Open the browser Network tab and filter for socket.io. You should see a polling request to socket.io/?EIO=4&transport=polling. Click it and inspect the response headers. If Access-Control-Allow-Origin is missing entirely, the Socket.IO server has no CORS config. If the header is present but its value doesn’t match the requesting origin (visible in the request’s Origin header), the allowed origin list is wrong.
Minute 2 — Check the request origin. The Origin header in the request tells you the exact string the server must allow. Copy it verbatim — http://localhost:3000 and http://localhost:3000/ are different values, and a trailing slash mismatch is enough to fail CORS.
Minute 4 — Verify CORS is set on Socket.IO, not Express. A common mistake is applying the cors npm middleware to Express and assuming Socket.IO inherits it. It does not. Socket.IO creates its own HTTP handler for the /socket.io/ path. CORS must be configured in the new Server() constructor, not in Express middleware.
Minute 6 — Check for a reverse proxy. If the app runs behind nginx, Caddy, or a cloud load balancer, the proxy may be adding its own Access-Control-Allow-Origin header or stripping the one Socket.IO sends. Two conflicting CORS headers in one response cause the browser to reject the request. Inspect the raw response headers in the Network tab — if you see a duplicate Access-Control-Allow-Origin, one comes from the proxy and one from Socket.IO.
Minute 8 — Test with curl. Run curl -v -H "Origin: http://localhost:3000" http://localhost:3001/socket.io/?EIO=4&transport=polling. The response headers should include Access-Control-Allow-Origin: http://localhost:3000. If they don’t, the server config is wrong regardless of what the code looks like.
Minute 10 — Check the client connection URL. If the client connects to http://localhost:3001 but the server is behind a proxy at https://app.example.com/api, the client sends Origin: http://localhost:3000 to a URL that may not match the server’s allowed origins. Match the client URL to the actual server endpoint, not the direct backend address.
Fix 1: Configure CORS on the Socket.IO Server
Socket.IO’s CORS configuration must be set when creating the server instance — not on an Express middleware applied after:
// WRONG — Express cors() middleware doesn't apply to Socket.IO
const app = express();
app.use(cors({ origin: 'http://localhost:3000' })); // ← Doesn't affect Socket.IO
const httpServer = createServer(app);
const io = new Server(httpServer); // No CORS config here → blocks all cross-origin
// CORRECT — set CORS directly on the Socket.IO Server constructor
const { Server } = require('socket.io');
const { createServer } = require('http');
const httpServer = createServer(app);
const io = new Server(httpServer, {
cors: {
origin: 'http://localhost:3000', // Exact origin
methods: ['GET', 'POST'],
credentials: true,
},
});Allow multiple origins:
const io = new Server(httpServer, {
cors: {
origin: [
'http://localhost:3000',
'https://app.example.com',
'https://staging.example.com',
],
methods: ['GET', 'POST'],
credentials: true,
},
});Dynamic origin validation:
const ALLOWED_ORIGINS = new Set([
'http://localhost:3000',
'https://app.example.com',
]);
const io = new Server(httpServer, {
cors: {
origin: (requestOrigin, callback) => {
// Allow requests with no origin (e.g., mobile apps, server-to-server)
if (!requestOrigin || ALLOWED_ORIGINS.has(requestOrigin)) {
callback(null, true);
} else {
callback(new Error(`Origin ${requestOrigin} not allowed`));
}
},
methods: ['GET', 'POST'],
credentials: true,
},
});Warning:
origin: '*'disables CORS protection entirely for all origins. Never use a wildcard in production. If you need to allow all origins (public API, no credentials), useorigin: truewhich reflects the request’s Origin header — this is safer than'*'and compatible with credentials.
Fix 2: Match Client Connection Options
The client-side connection options must be compatible with the server configuration:
// Basic connection
import { io } from 'socket.io-client';
const socket = io('http://localhost:3001', {
withCredentials: true, // Required if server has credentials: true
transports: ['websocket', 'polling'], // Try WebSocket first, fall back to polling
});
socket.on('connect_error', (err) => {
console.error('Connection error:', err.message);
// 'xhr poll error' = CORS issue or server down
// 'websocket error' = WebSocket upgrade failed
// 'timeout' = server took too long to respond
});Force WebSocket-only transport to skip the polling phase entirely (avoids CORS for the polling requests):
const socket = io('http://localhost:3001', {
transports: ['websocket'], // Skip HTTP polling — WebSocket only
});
// Note: This may fail in environments that block WebSocket upgrades (some corporate proxies)For React with auth token:
import { io, Socket } from 'socket.io-client';
import { useEffect, useRef } from 'react';
import { useAuth } from './hooks/useAuth';
function useSocket() {
const socketRef = useRef<Socket | null>(null);
const { token } = useAuth();
useEffect(() => {
socketRef.current = io(process.env.NEXT_PUBLIC_API_URL!, {
withCredentials: true,
auth: { token }, // Send token in handshake
transports: ['websocket', 'polling'],
});
socketRef.current.on('connect', () => {
console.log('Connected:', socketRef.current!.id);
});
return () => {
socketRef.current?.disconnect();
};
}, [token]);
return socketRef.current;
}Fix 3: Fix CORS When Using credentials: true
When the client sends cookies or auth headers, both the server and client must explicitly opt in:
Server — credentials: true in CORS config AND a specific origin (not *):
const io = new Server(httpServer, {
cors: {
origin: 'https://app.example.com', // Must be specific — NOT '*'
credentials: true,
},
});Client — withCredentials: true:
const socket = io('https://api.example.com', {
withCredentials: true, // Sends cookies with the connection
});Also configure Express CORS for the REST endpoints on the same server:
const cors = require('cors');
app.use(cors({
origin: 'https://app.example.com',
credentials: true,
}));
// Socket.IO handles its own CORS — set it on the Server constructor (not Express middleware)Session-based authentication with Socket.IO:
const session = require('express-session');
const { createServer } = require('http');
const { Server } = require('socket.io');
const sessionMiddleware = session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production',
sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax',
// sameSite: 'none' required for cross-origin cookies
},
});
app.use(sessionMiddleware);
const io = new Server(httpServer, {
cors: {
origin: 'https://app.example.com',
credentials: true,
},
});
// Share Express session with Socket.IO
io.engine.use(sessionMiddleware);
io.on('connection', (socket) => {
const session = socket.request.session;
console.log('User:', session.userId);
});Fix 4: Configure nginx or Reverse Proxy
In production, a reverse proxy (nginx, Caddy) often sits in front of the Node.js server. WebSocket connections require specific proxy configuration:
# nginx.conf — proxy Socket.IO correctly
upstream socketio_backend {
server 127.0.0.1:3001;
}
server {
listen 443 ssl;
server_name app.example.com;
location / {
proxy_pass http://socketio_backend;
proxy_http_version 1.1;
# Required for WebSocket upgrade
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Forward real client info
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Timeouts — WebSockets are long-lived connections
proxy_read_timeout 86400;
proxy_send_timeout 86400;
# Don't buffer — send data immediately
proxy_buffering off;
}
}If nginx adds its own CORS headers, they may conflict with Socket.IO’s headers:
# WRONG — don't set CORS headers in nginx if Socket.IO sets them
add_header 'Access-Control-Allow-Origin' '*'; # Conflicts with Socket.IO's specific origin
# Let Socket.IO handle CORS — only proxy the connection
location /socket.io/ {
proxy_pass http://socketio_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# No add_header CORS lines here
}Fix 5: Handle Namespace and Path Configuration
If your Socket.IO server uses a custom path or namespace, the client must match:
// Server — custom path
const io = new Server(httpServer, {
path: '/ws', // Default is '/socket.io'
cors: {
origin: 'http://localhost:3000',
credentials: true,
},
});
// Client — must specify the same path
const socket = io('http://localhost:3001', {
path: '/ws',
});Namespaces:
// Server
const adminNamespace = io.of('/admin');
adminNamespace.on('connection', (socket) => {
console.log('Admin connected');
});
// Client — connect to namespace
const adminSocket = io('http://localhost:3001/admin', {
withCredentials: true,
});React + Next.js API routes — Socket.IO on a Next.js server:
// pages/api/socket.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import { Server } from 'socket.io';
export default function handler(req: NextApiRequest, res: NextApiResponse) {
if ((res.socket as any).server.io) {
res.end();
return;
}
const io = new Server((res.socket as any).server, {
path: '/api/socket',
cors: {
origin: process.env.NEXTAUTH_URL,
credentials: true,
},
});
(res.socket as any).server.io = io;
io.on('connection', (socket) => {
console.log('Client connected:', socket.id);
});
res.end();
}
// Client
const socket = io({
path: '/api/socket',
});Fix 6: Debug Socket.IO Connection Issues
Enable Socket.IO debug logging to see exactly what’s failing:
// Client — enable debug logs
localStorage.debug = 'socket.io-client:*'; // In browser console
// Or set environment variable for Node.js client
// DEBUG=socket.io-client:* node client.jsServer-side logging:
const io = new Server(httpServer, {
cors: { origin: '*' },
});
io.engine.on('connection_error', (err) => {
console.error('Engine connection error:');
console.error(' Code:', err.code);
console.error(' Message:', err.message);
console.error(' Context:', err.context);
});Test the Socket.IO endpoint directly with curl:
# Test the polling endpoint (should return JSON)
curl -v "http://localhost:3001/socket.io/?EIO=4&transport=polling"
# Check CORS headers in the response
curl -v -H "Origin: http://localhost:3000" \
"http://localhost:3001/socket.io/?EIO=4&transport=polling"
# Look for: Access-Control-Allow-Origin: http://localhost:3000
# If missing, the server CORS config isn't workingCheck Socket.IO version compatibility:
# Server and client must use compatible major versions
# Socket.IO v4 server + v4 client ✓
# Socket.IO v3 server + v3 client ✓
# Socket.IO v4 server + v2 client ✗ (use allowEIO3: true for compatibility)// Allow Socket.IO v2 clients to connect to v4 server
const io = new Server(httpServer, {
allowEIO3: true, // Backward compatibility with v2 clients
cors: { origin: '*' },
});Still Not Working?
Check the browser Network tab — look for the socket.io/?EIO=4&transport=polling request. The response headers should include Access-Control-Allow-Origin matching your client’s origin. If the header is missing, the CORS config on the Socket.IO server isn’t being applied.
Verify the Socket.IO server is receiving the connection attempt — add a log to io.engine.on('initial_headers'):
io.engine.on('initial_headers', (headers, req) => {
console.log('Origin:', req.headers.origin);
console.log('Response headers:', headers);
});Localhost HTTPS mismatch — if your client runs on https://localhost:3000 and the server on http://localhost:3001, browsers treat these as different origins AND different security contexts. Use matching protocols or a proxy.
Multiple Socket.IO server instances — if your app runs on multiple Node.js processes (with PM2 cluster mode or multiple Heroku dynos), WebSocket connections get routed to different instances and lose state. Use @socket.io/redis-adapter to share state across instances:
const { createAdapter } = require('@socket.io/redis-adapter');
const { createClient } = require('redis');
const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();
Promise.all([pubClient.connect(), subClient.connect()]).then(() => {
io.adapter(createAdapter(pubClient, subClient));
});Docker networking with separate containers — if the Socket.IO server runs in one Docker container and the client app in another, localhost inside a container refers to that container, not the host. Use the Docker service name or the host machine’s IP. In Docker Compose, connect via the service name (e.g., http://socketio-server:3001).
Cloudflare or CDN stripping WebSocket headers — some CDN configurations do not pass the Upgrade header needed for WebSocket connections. Verify your CDN plan supports WebSocket passthrough, and check that the CDN is not caching the /socket.io/ polling responses. In Cloudflare, WebSocket support is enabled by default on all plans, but page rules or transform rules can interfere.
Cookie SameSite attribute blocking cross-origin credentials — if the server sets cookies with SameSite=Strict or SameSite=Lax, the browser will not send those cookies on cross-origin Socket.IO polling requests. Set SameSite=None; Secure on any cookies the Socket.IO connection needs, and make sure the server uses HTTPS.
For related networking issues, see Fix: Flask CORS Not Working, Fix: Next.js CORS Error, Fix: Express CORS Not Working, and Fix: CORS Access-Control-Allow-Origin.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Fastify Not Working — 404, Plugin Encapsulation, and Schema Validation Errors
How to fix Fastify issues — route 404 from plugin encapsulation, reply already sent, FST_ERR_VALIDATION, request.body undefined, @fastify/cors, hooks not running, and TypeScript type inference.
Fix: Bun Not Working — Node.js Module Incompatible, Native Addon Fails, or bun test Errors
How to fix Bun runtime issues — Node.js API compatibility, native addons (node-gyp), Bun.serve vs Node http, bun test differences from Jest, and common package incompatibilities.
Fix: Node.js Stream Error — Pipe Not Working, Backpressure, or Premature Close
How to fix Node.js stream issues — pipe and pipeline errors, backpressure handling, Transform streams, async iteration, error propagation, and common stream anti-patterns.
Fix: Node.js UnhandledPromiseRejection and uncaughtException — Crashing Server
How to handle Node.js uncaughtException and unhandledRejection events — graceful shutdown, error logging, async error boundaries, and keeping servers alive safely.