Fix: Socket.IO CORS Error — Cross-Origin Connection Blocked
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.
Key causes:
- No CORS configuration on the Socket.IO server — the default Socket.IO server in v3+ rejects all cross-origin connections. In v2, CORS was allowed by default (a security regression that v3 fixed).
- 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.
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));
});For related networking issues, see Fix: Flask CORS Not Working and Fix: Next.js CORS Error.
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.