El intercambio de recursos entre orígenes, o CORS, sigue siendo una de las fuentes de confusión más tenaces del desarrollo web. Entender qué protege el navegador y qué cabeceras debe devolver el servidor evita tanto los errores que bloquean como las configuraciones peligrosamente permisivas.
Qué protege realmente el navegador
Por defecto, una página servida desde un origen no puede leer la respuesta de una petición enviada a otro origen. Un origen son tres elementos: el esquema, el dominio y el puerto. https://app.exemple.com y https://api.exemple.com son, por tanto, dos orígenes distintos, igual que un mismo dominio servido por HTTP y por HTTPS. Esa regla, la política del mismo origen, impide que un sitio malicioso lea los datos de un servicio donde la víctima está autenticada.
CORS no debilita esa protección: ofrece al servidor una manera de autorizar de forma explícita a ciertos orígenes a leer sus respuestas. La petición se envía igualmente, pero el navegador oculta la respuesta al código JavaScript mientras las cabeceras adecuadas no lo permitan. El matiz es decisivo: CORS protege a quien lee, no al servidor, que debe conservar sus propios controles de acceso.
Peticiones simples y peticiones con verificación previa
El navegador distingue dos casos. Una petición llamada simple (método GET, HEAD o POST, cabeceras básicas, tipo de contenido estándar) sale directamente. En cuanto una petición se sale de ese marco, por ejemplo un PUT, una cabecera Authorization o un cuerpo JSON, el navegador envía primero una petición de verificación previa, el preflight, con el método OPTIONS. Ese paso pregunta al servidor si acepta la llamada antes de ejecutarla.
OPTIONS /api/orders HTTP/1.1
Host: api.exemple.com
Origin: https://app.exemple.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.exemple.com
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: content-type, authorization
Access-Control-Max-Age: 600
Vary: OriginMientras el preflight no reciba una respuesta favorable, la petición real nunca se emite. Un error CORS en la consola casi siempre señala a un servidor que responde mal a ese OPTIONS, no a un fallo del cliente.
Las cabeceras del lado del servidor
El contrato CORS cabe en un puñado de cabeceras de respuesta. Conocerlas una a una despeja la mayoría de los bloqueos.
| Cabecera | Función |
|---|---|
Access-Control-Allow-Origin | origen autorizado, un único valor o * |
Access-Control-Allow-Methods | métodos aceptados, en respuesta al preflight |
Access-Control-Allow-Headers | cabeceras de petición autorizadas |
Access-Control-Allow-Credentials | autoriza cookies y credenciales, incompatible con * |
Access-Control-Max-Age | tiempo de caché del preflight, en segundos |
Access-Control-Expose-Headers | cabeceras de respuesta legibles por JavaScript |
Configurarlo según la pila
La lógica es idéntica en todas partes: reconocer el origen que llama, compararlo con una lista blanca y devolver las cabeceras correspondientes. En Node, basta un middleware.
const ALLOWED = new Set([
"https://app.exemple.com",
"https://admin.exemple.com",
]);
app.use((req, res, next) => {
const origin = req.headers.origin;
if (origin && ALLOWED.has(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Access-Control-Allow-Credentials", "true");
res.setHeader("Vary", "Origin");
}
if (req.method === "OPTIONS") {
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
res.setHeader("Access-Control-Max-Age", "600");
return res.status(204).end();
}
next();
});El mismo contrato puede vivir en el servidor frontal, lo que evita cargar la aplicación para simples peticiones OPTIONS.
location /api/ {
set $cors "";
if ($http_origin ~* ^https://(app|admin)\.exemple\.com$) {
set $cors $http_origin;
}
add_header Access-Control-Allow-Origin $cors always;
add_header Vary Origin always;
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
add_header Access-Control-Max-Age 600 always;
return 204;
}
proxy_pass http://backend;
}Cookies, credenciales y trampas clásicas
La trampa más extendida combina Access-Control-Allow-Origin: * con el envío de cookies. La especificación lo prohíbe: en cuanto la petición transporta credenciales, el origen debe devolverse por su nombre y Access-Control-Allow-Credentials pasar a true. Reflejar dinámicamente el origen recibido obliga entonces a añadir Vary: Origin, o una caché podría servir la autorización equivocada a otro origen.
Un asterisco que autoriza a todo el mundo y cookies en la misma respuesta: la especificación lo rechaza, y es una buena noticia.
Se repiten otras dos confusiones. Un código de error HTTP correcto acompañado de cabeceras CORS ausentes aparece en la consola como un error CORS, aunque el servidor sí respondió. Y JavaScript solo puede leer, en una respuesta entre orígenes, las cabeceras declaradas en Access-Control-Expose-Headers, un detalle que a menudo bloquea la lectura de una cabecera de paginación o de un token.
Reconstruir la cabecera Access-Control-Allow-Origin a partir del origen recibido sin validarlo contra una lista blanca equivale a autorizarlo todo. Una expresión regular demasiado amplia, del tipo exemple.com sin anclaje, deja pasar exemple.com.atacante.net. La lista blanca explícita es el único enfoque seguro.
Probar y depurar sin adivinar
Reproducir el comportamiento del navegador en la línea de comandos aísla la causa de inmediato. Una llamada OPTIONS forjada con curl muestra si el servidor devuelve las cabeceras de preflight correctas, antes incluso de abrir el navegador.
# Reproduce the preflight the browser would send
curl -i -X OPTIONS https://api.exemple.com/api/orders \
-H "Origin: https://app.exemple.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: content-type, authorization"
# Then replay the real request and inspect its CORS headers
curl -i https://api.exemple.com/api/orders \
-H "Origin: https://app.exemple.com"En la pestaña de red de las herramientas de desarrollo, una petición bloqueada por CORS muestra un estado pero una respuesta ilegible, mientras que la consola detalla la cabecera que falta. Ese par, mensaje en consola y luego repetición con curl, basta para decidir en segundos entre un error de configuración del servidor y un fallo real de la aplicación. Un preflight que responde bien con curl pero falla en el navegador casi siempre apunta a un intermediario, un proxy o un CDN, que filtra el método OPTIONS o reescribe las cabeceras.
Subdominios, entornos y cookies
A la cuestión de las credenciales se suma el atributo SameSite de las cookies, independiente de CORS pero a menudo confundido con él. Una cookie con SameSite=Lax no se enviará en una petición de fondo a otro sitio, ni siquiera con una configuración CORS perfecta. Una autenticación compartida entre subdominios distintos exige, por tanto, una cookie con SameSite=None; Secure y credenciales autorizadas en el servidor. Cuando conviven varios entornos, preproducción y producción en subdominios vecinos, conviene derivar la lista blanca de una variable de entorno en lugar de codificarla a mano, para que una URL de pruebas no se filtre en producción.
Lo que hay que recordar
CORS no es un obstáculo que esquivar sino un contrato que honrar. El navegador aplica la política del mismo origen; el servidor elige, con conocimiento de causa, qué orígenes autoriza a leer sus respuestas. Una lista blanca estricta, un preflight bien atendido, un Vary: Origin cuando el origen es dinámico, y la inmensa mayoría de los errores desaparecen. La tentación del comodín * solo se justifica para recursos realmente públicos y sin credenciales. Para todo lo demás, nombrar los orígenes cuesta unas líneas y protege de forma duradera.
En mis proyectos, la primera pregunta cuando una petición se rompe en producción ya no es «¿de dónde sale este error CORS?» sino «¿responde el servidor correctamente al OPTIONS?». Nueve de cada diez veces, ahí está la respuesta. Acabé desterrando el * de mis API autenticadas y centralizando la lista blanca en una única constante, revisada en cada revisión. Es menos vistoso que un arreglo ingenioso, pero me evita reabrir el mismo ticket cada trimestre. — Simon Janvier
Para profundizar: la referencia de CORS en MDN Web Docs y la especificación Fetch del WHATWG.
