Skip to content

Fix: Socket.IO CORS Error — Cross-Origin Connection Blocked

FixDevs ·

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: 400

Or 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 credentialsorigin: '*' 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), use origin: true which 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:

Servercredentials: 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,
  },
});

ClientwithCredentials: 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.js

Server-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 working

Check 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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles