Saltar al contenido principal

Configuración Definitiva de CORS en Node.js/Express para Producción

Cómo configurar CORS correctamente en Express y Node.js para producción. Los errores más comunes, por qué tu wildcard no funciona con cookies, y la config que uso en mis APIs reales.

Fran Cobos 7 min de lectura 1213 palabras

Tabla de contenidos

Si estás leyendo esto, probablemente estás viendo este error en la consola:

Access to fetch at 'http://localhost:4000/api/users' from origin 'http://localhost:3000'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present
on the requested resource.

Lo he visto cientos de veces. Y la “solución” que encuentra todo el mundo en Stack Overflow es:

// ❌ La "solución" de Stack Overflow que NO debes usar en producción
app.use(cors());

Esto permite que cualquier web del mundo haga peticiones a tu API. Para desarrollo local, vale. Para producción con autenticación, es un agujero de seguridad.

Cómo funciona CORS (la versión corta)

  1. Tu frontend en https://miapp.com hace fetch('https://api.miapp.com/users')
  2. El navegador ve que son dominios diferentes → activa CORS
  3. El navegador envía una petición preflight (OPTIONS) preguntando: “¿puedo hacer esta petición?”
  4. El servidor responde con headers diciendo: “sí, miapp.com puede hacer GET y POST”
  5. Solo entonces el navegador permite la petición real

Si el servidor no responde con los headers correctos → el navegador bloquea la respuesta. Tu API procesó la petición, pero el navegador no te deja ver el resultado.

La configuración que uso en producción

// src/middleware/cors.js
import cors from 'cors';

const allowedOrigins = [
  'https://miapp.com',
  'https://www.miapp.com',
  'https://admin.miapp.com',
];

// En desarrollo, añadir localhost
if (process.env.NODE_ENV !== 'production') {
  allowedOrigins.push(
    'http://localhost:3000',
    'http://localhost:5173', // Vite
    'http://localhost:4321', // Astro
  );
}

export const corsMiddleware = cors({
  origin: function (origin, callback) {
    // Permitir peticiones sin origin (Postman, curl, server-to-server)
    if (!origin) return callback(null, true);

    if (allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error(`Origin ${origin} not allowed by CORS`));
    }
  },
  credentials: true,                    // Permite enviar cookies
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  exposedHeaders: ['X-Total-Count'],     // Headers custom que el frontend puede leer
  maxAge: 86400,                         // Cache preflight 24h (reduce peticiones OPTIONS)
});
// src/app.js
import express from 'express';
import { corsMiddleware } from './middleware/cors.js';

const app = express();

// CORS debe ir ANTES de todas las rutas
app.use(corsMiddleware);

app.use(express.json());

// Tus rutas aquí
app.use('/api/users', usersRouter);
app.use('/api/tasks', tasksRouter);

Los 7 errores de CORS más comunes (y cómo solucionarlos)

Error 1: cors() sin configurar (wildcard implícito)

// ❌ Permite TODO. No usar en producción con auth.
app.use(cors());

Esto equivale a Access-Control-Allow-Origin: *. Si tu API usa cookies o tokens de sesión, el navegador rechazará las peticiones con credentials: 'include' cuando el origin es wildcard.

// ✅ Especificar dominios
app.use(cors({ origin: 'https://miapp.com', credentials: true }));

Error 2: Wildcard + credentials = error

// ❌ Esto NO funciona
app.use(cors({ origin: '*', credentials: true }));
Access to fetch at '...' has been blocked by CORS policy:
The value of the 'Access-Control-Allow-Origin' header must not be '*'
when the request's credentials mode is 'include'.

El navegador prohíbe wildcard con credentials. Es una protección de seguridad: si permitieras cualquier origen con cookies, una web maliciosa podría hacer peticiones autenticadas a tu API.

// ✅ Origin específico con credentials
app.use(cors({ 
  origin: 'https://miapp.com', 
  credentials: true 
}));

Error 3: El frontend no envía credentials: 'include'

Tu servidor está bien configurado, pero el frontend no envía las cookies:

// ❌ Fetch sin credentials — no envía cookies
fetch('https://api.miapp.com/users');

// ✅ Fetch con credentials — envía cookies del dominio
fetch('https://api.miapp.com/users', {
  credentials: 'include',
});

// ✅ Con axios
axios.get('https://api.miapp.com/users', {
  withCredentials: true,
});

Error 4: Preflight bloqueado porque OPTIONS no responde

// ❌ Si tienes rutas que bloquean OPTIONS
app.use('/api', authMiddleware, apiRoutes);
// El middleware de auth bloquea la petición OPTIONS (no tiene token)
// ✅ CORS middleware ANTES del auth middleware
app.use(corsMiddleware);     // Responde a OPTIONS sin auth
app.use('/api', authMiddleware, apiRoutes);

El preflight (OPTIONS) no lleva cookies ni tokens. Si tu middleware de autenticación está antes que CORS, bloqueará el preflight y ninguna petición pasará.

Error 5: No incluir Content-Type en allowedHeaders

// ❌ El browser envía Content-Type: application/json
// pero el servidor no lo permite
app.use(cors({ allowedHeaders: ['Authorization'] }));
// ✅ Incluir Content-Type
app.use(cors({ allowedHeaders: ['Content-Type', 'Authorization'] }));

Los “simple headers” (Accept, Content-Language, Content-Type con ciertos valores) no necesitan declararse. Pero si envías Content-Type: application/json, el navegador lo trata como un header “no simple” y requiere que esté en allowedHeaders.

Error 6: Headers personalizados no visibles en el frontend

// En el backend
res.setHeader('X-Total-Count', '142');

// En el frontend
const count = response.headers.get('X-Total-Count'); // null

Los navegadores solo exponen los “CORS-safelisted headers” al frontend. Para headers custom, necesitas exposedHeaders:

// ✅ Exponer headers custom
app.use(cors({ exposedHeaders: ['X-Total-Count', 'X-Request-Id'] }));

Error 7: CORS en producción con reverse proxy (Nginx)

Si tu API está detrás de Nginx, CORS puede configurarse en Nginx O en Express, pero no en ambos. Si ambos añaden Access-Control-Allow-Origin, el navegador recibe el header duplicado y lo rechaza:

The 'Access-Control-Allow-Origin' header contains multiple values
'https://miapp.com, https://miapp.com', but only one is allowed.
# ✅ Opción 1: CORS en Nginx (y desactivar en Express)
location /api/ {
    proxy_pass http://localhost:4000;

    add_header Access-Control-Allow-Origin "https://miapp.com" always;
    add_header Access-Control-Allow-Credentials true always;
    add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
    add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;

    if ($request_method = OPTIONS) {
        return 204;
    }
}
// ✅ Opción 2: CORS en Express (recomendado — más control)
// No tocar Nginx, dejar que Express maneje CORS

Yo prefiero manejar CORS en Express porque es más fácil de debuggear y mantener que editando configs de Nginx.

Configuración para múltiples dominios dinámicos

Si tienes un SaaS multi-tenant donde cada cliente tiene su subdominio:

const corsMiddleware = cors({
  origin: function (origin, callback) {
    if (!origin) return callback(null, true);

    // Permitir cualquier subdominio de miapp.com
    const allowedPattern = /^https:\/\/[\w-]+\.miapp\.com$/;

    if (allowedPattern.test(origin) || allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  },
  credentials: true,
});

Cuidado: El regex debe ser estricto. Un regex laxo como /miapp\.com/ permitiría evil-miapp.com (spoofing).

Config para APIs públicas (sin auth)

Si tu API es pública y no usa cookies:

// ✅ Para APIs públicas — wildcard está bien
app.use(cors());
// Equivale a: Access-Control-Allow-Origin: *

Ejemplos donde wildcard es correcto:

  • API de precios de criptomonedas
  • API de datos abiertos del gobierno
  • CDN de recursos estáticos
  • API de herramientas públicas (conversor de moneda, clima, etc.)

Mi checklist de CORS para producción

  • origin especifica los dominios exactos (no wildcard con auth)
  • credentials: true si usas cookies de sesión
  • allowedHeaders incluye Content-Type y Authorization
  • CORS middleware va ANTES del auth middleware
  • Preflight (OPTIONS) responde con 204 sin pasar por auth
  • maxAge: 86400 para cachear preflight y reducir latencia
  • No duplicar headers CORS entre Nginx y Express
  • Los origenes de desarrollo (localhost) solo se añaden en NODE_ENV !== 'production'
  • exposedHeaders declarados si el frontend lee headers custom
  • Logs de errores CORS para detectar intentos sospechosos

Artículos relacionados

Fran Cobos

Fran Cobos

Desarrollador Full Stack especializado en IA aplicada, automatización y desarrollo web. Escribo sobre herramientas, tutoriales y casos reales para programadores.

¿Necesitas desarrollo a medida?

Apps web, IA, módulos ERP — cuéntame tu proyecto.