Políticas CORS
Esta semana completaste el ciclo de vida completo de una petición en NestJS. El siguiente paso natural es conectar esa API a un frontend React — y en el momento en que el frontend está en un dominio distinto al backend, el navegador aplica CORS. Este material explica exactamente qué es, cómo funciona y cómo configurarlo sin dejar la API innecesariamente expuesta.
1. La Política de Mismo Origen #
Los navegadores implementan la Same-Origin Policy (SOP): un script de una página solo puede hacer peticiones HTTP al mismo origen del que vino. Si el origen es distinto, el navegador bloquea la petición.
Origen = esquema + host + puerto. Los tres deben coincidir:
http://localhost:3000 y http://localhost:4000 → distinto puerto → CORS
http://localhost:3000 y https://localhost:3000 → distinto esquema → CORS
http://miapp.com y http://api.miapp.com → distinto host → CORS
http://miapp.com y http://miapp.com → mismo origen → sin restricción
Por qué existe #
Sin SOP, un sitio malicioso podría hacer peticiones a tu banco desde el navegador de un usuario autenticado y leer la respuesta. La SOP garantiza que malicioso.com no puede leer respuestas de banco.com, aunque el usuario tenga sesión activa.
CORS es la excepción controlada #
CORS (Cross-Origin Resource Sharing) es el mecanismo que le permite al servidor declarar explícitamente qué orígenes distintos pueden acceder a sus recursos. El servidor no bloquea — el navegador bloquea por defecto y el servidor puede levantar esa restricción con los headers correctos.
Frontend (localhost:3000) ──petición──▶ API (localhost:4000)
◀──respuesta── API incluye Access-Control-Allow-Origin
Navegador comprueba el header:
- coincide con el origen del frontend → deja pasar la respuesta
- no coincide o no existe → bloquea la respuesta (CORS error)
CORS no protege el servidor — protege el navegador.
curl, Postman y cualquier cliente que no sea un navegador ignoran completamente CORS. El error de CORS que ves en la consola del navegador significa que la API respondió — el navegador fue quien bloqueó que tu código JavaScript leyera esa respuesta.
2. Los Headers de CORS #
En la respuesta del servidor (response headers) #
| Header | Qué controla | Ejemplo |
|---|---|---|
Access-Control-Allow-Origin | Qué origen puede leer la respuesta | https://miapp.com o * |
Access-Control-Allow-Methods | Qué métodos HTTP están permitidos | GET, POST, PUT, DELETE |
Access-Control-Allow-Headers | Qué headers puede enviar el cliente | Content-Type, Authorization |
Access-Control-Allow-Credentials | Si se permiten cookies y headers de auth | true |
Access-Control-Max-Age | Segundos que el navegador cachea el preflight | 86400 |
Access-Control-Expose-Headers | Headers de la respuesta que el JS puede leer | X-Total-Count |
En la petición del cliente (request headers — enviados automáticamente por el navegador) #
| Header | Qué informa |
|---|---|
Origin | El origen del frontend que hace la petición |
Access-Control-Request-Method | El método que se va a usar (en preflight) |
Access-Control-Request-Headers | Los headers que se van a enviar (en preflight) |
3. Peticiones Simples vs Preflighted #
No todas las peticiones desencadenan el mismo flujo de CORS.
Peticiones simples #
Una petición es “simple” si cumple todas estas condiciones:
- Método:
GET,POSToHEAD - Content-Type:
text/plain,multipart/form-dataoapplication/x-www-form-urlencoded - Sin headers personalizados (no
Authorization, noX-Custom-Header)
Las peticiones simples van directamente al servidor. El navegador lee el Access-Control-Allow-Origin de la respuesta y decide si deja que tu JS acceda a ella.
JS en localhost:3000
──GET /api/cursos──▶ API en localhost:4000
◀──200 OK + Access-Control-Allow-Origin: http://localhost:3000──
Navegador: ✅ el origen coincide → JS puede leer la respuesta
Peticiones preflighted #
Cualquier petición que no sea “simple” desencadena primero una petición OPTIONS automática — el preflight. El navegador pregunta al servidor “¿puedo hacer esta petición?”. Solo si el servidor responde correctamente, el navegador envía la petición real.
Condiciones que activan el preflight:
- Método:
PUT,DELETE,PATCH - Content-Type:
application/json - Cualquier header personalizado (
Authorization,X-Request-ID, etc.)
JS en localhost:3000 quiere hacer POST con JSON y Authorization:
1. PREFLIGHT (automático, antes de la petición real):
──OPTIONS /api/cursos──▶ API en localhost:4000
Origin: http://localhost:3000
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization
◀──204 No Content──
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
2. PETICIÓN REAL (solo si el preflight fue exitoso):
──POST /api/cursos──▶ API en localhost:4000
Authorization: Bearer eyJ...
Content-Type: application/json
◀──201 Created──
Si el servidor no responde al OPTIONS con los headers correctos, la petición real nunca se envía. El error de CORS en el navegador en estos casos viene del preflight fallido, no de la petición real.
4. Configuración en Express #
npm install cors
npm install -D @types/cors
Permitir todos los orígenes — solo para desarrollo #
import cors from "cors";
app.use(cors());
// equivale a: Access-Control-Allow-Origin: *
"*" permite cualquier origen pero tiene una restricción importante: es incompatible con credentials: true. Si tu frontend envía cookies o el header Authorization, no puedes usar "*".
Lista blanca explícita #
const ORIGENES_PERMITIDOS = [
"http://localhost:3000", // desarrollo frontend
"https://miapp.com", // producción
"https://staging.miapp.com", // staging
];
app.use(
cors({
origin: (origin, callback) => {
// origin es undefined en peticiones server-to-server (curl, Postman)
if (!origin || ORIGENES_PERMITIDOS.includes(origin)) {
callback(null, true);
} else {
callback(new Error(`Origen no permitido: ${origin}`));
}
},
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization"],
credentials: true, // permite cookies y el header Authorization
maxAge: 86400, // cachea el preflight 24 horas
})
);
credentials: true — cuándo y por qué #
credentials: true es necesario cuando el frontend envía:
- Cookies (
document.cookie, cookies de sesión) - El header
Authorizationen peticionesfetchconcredentials: 'include'
Cuando credentials: true, el navegador exige que Access-Control-Allow-Origin sea un origen específico — nunca "*". Si el servidor responde con "*" y el cliente envía credenciales, el navegador bloquea igualmente.
// ❌ Incompatible — el navegador lo rechaza aunque llegue la respuesta
app.use(cors({ origin: "*", credentials: true }));
// ✅ Origen específico con credenciales
app.use(cors({ origin: "http://localhost:3000", credentials: true }));
5. Configuración en NestJS #
NestJS expone la configuración de CORS a través de app.enableCors():
// main.ts
const app = await NestFactory.create(AppModule);
// Forma simple — todos los orígenes (solo desarrollo)
app.enableCors();
// Forma completa — equivale a la configuración de cors() en Express
app.enableCors({
origin: (origin, callback) => {
const permitidos = [
"http://localhost:3000",
"https://miapp.com",
];
if (!origin || permitidos.includes(origin)) {
callback(null, true);
} else {
callback(new Error("Origen no permitido"));
}
},
methods: "GET,POST,PUT,PATCH,DELETE,OPTIONS",
allowedHeaders: "Content-Type,Authorization",
credentials: true,
maxAge: 86400,
});
await app.listen(4000);
Internamente, enableCors() configura el mismo paquete cors de Express.
Leer orígenes desde variables de entorno #
// .env
CORS_ORIGINS=http://localhost:3000,https://miapp.com,https://staging.miapp.com
// main.ts
const origenes = (process.env.CORS_ORIGINS ?? "")
.split(",")
.map(o => o.trim())
.filter(Boolean);
app.enableCors({
origin: (origin, callback) => {
if (!origin || origenes.includes(origin)) {
callback(null, true);
} else {
callback(new Error("Origen no permitido"));
}
},
credentials: true,
});
Esto permite cambiar los orígenes permitidos por entorno (desarrollo, staging, producción) sin tocar el código.
6. Exponer Headers Personalizados al Cliente #
Por defecto, el navegador solo expone al JavaScript del cliente un subconjunto de headers de la respuesta. Si tu API devuelve headers personalizados (como X-Total-Count para paginación), debes declararlos explícitamente:
app.enableCors({
origin: "http://localhost:3000",
exposedHeaders: ["X-Total-Count", "X-Request-Id"],
// sin esto, response.headers.get("X-Total-Count") devuelve null en el frontend
});
7. El Error de CORS — Cómo Leerlo #
El error más común en la consola del navegador:
Access to fetch at 'http://localhost:4000/api/cursos' from origin 'http://localhost:3000'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present
on the requested resource.
Lo que significa: la petición llegó al servidor, el servidor respondió, pero la respuesta no tenía el header Access-Control-Allow-Origin — o tenía un valor que no coincide con el origen del frontend.
Diagnóstico rápido #
Error: "No 'Access-Control-Allow-Origin' header"
→ CORS no está configurado en el servidor, o el middleware no está activo
Error: "has been blocked by CORS policy: The value of the 'Access-Control-Allow-Origin'
header must not be the wildcard '*' when the request's credentials mode is 'include'"
→ Estás usando origin: "*" con credentials: true — son incompatibles
Error en preflight: "Method DELETE is not allowed by Access-Control-Allow-Methods"
→ El método DELETE no está en la lista de métodos permitidos
Error: "Response to preflight request doesn't pass access control check"
→ El endpoint OPTIONS no está respondiendo correctamente — verifica que el middleware
de CORS esté antes que las rutas y que maneje OPTIONS
Preguntas Frecuentes #
¿Por qué Postman no tiene errores de CORS pero el navegador sí? #
CORS es una restricción del navegador, no del servidor. Postman, curl, Node.js fetch y cualquier cliente que no sea un navegador no aplican la política de mismo origen. El servidor siempre responde — solo el navegador decide si tu JavaScript puede leer esa respuesta.
¿Tengo que configurar CORS en desarrollo si el frontend y el backend corren en el mismo host? #
Sí, si corren en puertos distintos. localhost:3000 y localhost:4000 son orígenes distintos aunque el host sea localhost. Solo estarías exento si sirves el frontend desde el mismo proceso Node.js que la API.
¿credentials: true es solo para cookies? #
No — es para cualquier petición que incluya credenciales en el sentido de HTTP: cookies, certificados de cliente TLS, o headers de autenticación HTTP básica. El header Authorization: Bearer ... no cuenta como credencial en este sentido — puedes enviarlo con origin: "*". Las cookies sí cuentan y sí requieren credentials: true.
Nota: si usas
fetchconcredentials: 'include'en el frontend, eso fuerza al navegador a tratar la petición como credencial y exige que el servidor responda conAccess-Control-Allow-Credentials: truey un origen específico (no*).
¿Qué es maxAge y vale la pena configurarlo? #
maxAge (en segundos) le dice al navegador cuánto tiempo puede cachear la respuesta del preflight. Sin él, el navegador hace un OPTIONS antes de cada petición no simple — lo que puede agregar latencia visible en aplicaciones con muchas peticiones. Un valor de 86400 (24 horas) elimina prácticamente todo el overhead de preflight en producción.
¿Puedo bloquear CORS completamente para una API pública? #
“Bloquear CORS” no tiene sentido para una API pública — CORS abre acceso desde orígenes cruzados. Una API completamente pública usa origin: "*" para que cualquier frontend pueda consumirla. “Sin CORS configurado” no bloquea las peticiones — solo hace que el navegador bloquee la lectura de las respuestas en frontends de otros orígenes.
¿El middleware de CORS debe ir antes o después de los Guards y rutas? #
Siempre antes de cualquier otra cosa — incluyendo rutas, Guards y middleware de autenticación. Si el preflight OPTIONS llega al Guard de JWT antes del middleware de CORS, el Guard lo rechaza con 401 antes de que CORS pueda procesarlo. El navegador recibe un error de autenticación, no la respuesta de CORS, y el error que ves en la consola es confuso.
// ✅ CORS siempre primero
const app = await NestFactory.create(AppModule);
app.enableCors({ ... }); // primero
app.useGlobalGuards(...); // después
app.useGlobalPipes(...);
app.useGlobalFilters(...);
await app.listen(4000);
Resumen #
| Concepto | Clave |
|---|---|
| Same-Origin Policy | Esquema + host + puerto deben coincidir — el navegador bloquea lo demás |
| CORS abre excepciones | El servidor declara qué orígenes cruzados puede leer su respuesta |
| CORS no protege el servidor | curl y Postman ignoran CORS — solo aplica en navegadores |
| Petición simple | GET/POST sin headers personalizados ni JSON → sin preflight |
| Petición preflighted | PUT/DELETE/PATCH, JSON body, Authorization → OPTIONS primero |
Access-Control-Allow-Origin: * | Válido si no hay credenciales — incompatible con credentials: true |
credentials: true | Necesario para cookies — exige origen específico, no "*" |
| Lista blanca de orígenes | Función (origin, callback) que valida contra un array de permitidos |
maxAge: 86400 | Cachea el preflight 24 horas — elimina el overhead de OPTIONS repetidos |
exposedHeaders | Declara qué headers personalizados puede leer el JS del cliente |
| CORS antes que todo | enableCors() o app.use(cors()) antes de rutas, Guards y middleware de auth |
Variable de entorno CORS_ORIGINS | Lista separada por comas — permite cambiar orígenes sin tocar código |