La política del mismo origen y lo que CORS relaja
Un origen son tres cosas juntas: esquema, host y puerto. https://shop.example.com y https://api.example.com son orígenes distintos, y también lo son http://example.com y https://example.com. La ruta no cuenta.
Los navegadores aplican la política del mismo origen (same-origin policy): el JavaScript de un origen puede enviar peticiones a otro, pero no puede leer la respuesta salvo que el otro lado lo acepte. La misma regla cubre las fuentes web cargadas desde otro host y la lectura de los píxeles de una imagen de otro origen dibujada en un canvas.
Muchas cosas siguen permitidas sin ningún acuerdo. Una página puede incrustar una imagen, una hoja de estilos o un script de cualquier sitio, y un formulario puede hacer POST a cualquier web. Lo que la política protege es la lectura: sin ella, cualquier página que abras podría llamar en silencio a tu webmail o a la API de tu banco con tus cookies y leer la respuesta.
CORS (Cross-Origin Resource Sharing) es ese acuerdo. El navegador indica al servidor qué origen pregunta, en la cabecera de petición Origin, y el servidor responde con cabeceras Access-Control-* que dicen qué orígenes pueden leer la respuesta. Nada en CORS bloquea la petición en el servidor: es el navegador quien retiene la respuesta y no se la entrega a la página.
Peticiones simples y peticiones preflight
Las peticiones simples salen directamente. Un GET, HEAD o POST cuenta como simple cuando solo lleva cabeceras de la lista segura de CORS, como Accept, Accept-Language y Content-Language, y, si tiene cuerpo, un Content-Type text/plain, multipart/form-data o application/x-www-form-urlencoded. El navegador la envía con la cabecera Origin, recibe la respuesta y solo se la pasa a la página si Access-Control-Allow-Origin permite ese origen.
Todo lo demás lleva antes un preflight: una petición OPTIONS que pide permiso antes de enviar la de verdad. Indica el método en Access-Control-Request-Method y el resto de cabeceras en Access-Control-Request-Headers. El servidor tiene que contestar con un estado 2xx, las cabeceras Access-Control-Allow-* que correspondan y sin redirección. Solo entonces el navegador envía la petición real.
La sorpresa habitual es que una API JSON provoca un preflight en casi cada llamada. Content-Type: application/json no está en la lista segura y Authorization tampoco, así que un fetch que envía JSON con un token bearer paga un viaje de ida y vuelta extra, salvo que el navegador tenga aún en caché la respuesta del preflight. Para eso existe Access-Control-Max-Age: Chromium guarda esa respuesta como máximo dos horas y Firefox como máximo 24, y sin la cabecera el navegador la conserva cinco segundos.
Un preflight y la respuesta que deja pasar la petición real
OPTIONS /v1/orders HTTP/1.1
Host: api.example.com
Origin: https://shop.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: authorization, content-type
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://shop.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 7200
Vary: Origin
Las cabeceras Access-Control, una a una
Access-Control-Allow-Origin es la que decide. Admite exactamente un valor: * para cualquier origen, un único origen como https://shop.example.com, o null. Una lista separada por comas no es válida, y null no debería permitirse nunca, porque los iframes con sandbox y los archivos locales también envían Origin: null.
Access-Control-Allow-Methods y Access-Control-Allow-Headers responden al preflight: qué métodos y qué cabeceras de petición puede usar la petición real. Access-Control-Max-Age indica cuántos segundos puede reutilizar el navegador esa respuesta.
Access-Control-Allow-Credentials: true permite que la página lea la respuesta a una petición enviada con cookies o autenticación HTTP. Las reglas que trae consigo están en la siguiente sección.
Access-Control-Expose-Headers enumera las cabeceras de respuesta que el JavaScript de la página puede leer. Sin ella, un script solo ve Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified y Pragma, así que un X-Request-Id propio, o el ETag que necesita una librería de subida, quedan invisibles.
En el lado de la petición hay tres cabeceras, todas escritas por el navegador e imposibles de falsificar desde el código de la página: Origin, Access-Control-Request-Method y Access-Control-Request-Headers.
Credenciales: cookies, Authorization y por qué * deja de funcionar
Una petición lleva credenciales cuando incluye cookies o autenticación HTTP: fetch con credentials: 'include', o XMLHttpRequest con withCredentials = true. En esas peticiones el navegador endurece todas las reglas.
Access-Control-Allow-Origin debe nombrar el origen exacto; * se rechaza. Tiene que estar Access-Control-Allow-Credentials: true. Y un * en Allow-Headers, Allow-Methods o Expose-Headers deja de ser comodín: se lee como una cabecera que se llama literalmente *.
Como una respuesta solo puede nombrar un origen, un servidor con varios front-ends mantiene una lista de permitidos, compara con ella el Origin que llega y devuelve el que coincide. La respuesta depende ahora de una cabecera de la petición, así que todas las respuestas, también las que no llevan ninguna cabecera CORS, deben decir Vary: Origin; si no, una caché entrega la respuesta de un origen a otro.
El error clásico es devolver cualquier origen y a la vez permitir credenciales. Eso deja que cualquier web lea los datos de tus usuarios con sesión iniciada a través de sus propios navegadores. Es lo primero que marcan los escáneres de seguridad y entra en el control de acceso roto del OWASP Top 10. Las cookies tienen además su propia puerta: una cookie solo viaja en una petición cross-site si se fijó con SameSite=None y Secure.
Una lista de permitidos que devuelve el origen (Node.js, Express)
const allowed = new Set([
'https://shop.example.com',
'https://admin.example.com',
]);
app.use((req, res, next) => {
const origin = req.headers.origin;
res.vary('Origin');
if (allowed.has(origin)) {
res.set('Access-Control-Allow-Origin', origin);
res.set('Access-Control-Allow-Credentials', 'true');
}
if (req.method === 'OPTIONS') {
res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type');
res.set('Access-Control-Max-Age', '7200');
return res.sendStatus(204);
}
next();
});
Errores CORS frecuentes y qué significa cada uno
El mensaje de la consola nombra la regla que ha fallado. Léelo con la pestaña Network abierta, porque el código de estado de la petición fallida suele contar la historia real. Los mensajes siguientes son los textos de Chromium.
No 'Access-Control-Allow-Origin' header is present on the requested resource. La respuesta llegó sin la cabecera. Casi siempre es una respuesta de error, como un 404, un 500, una redirección a la página de login o el 403 de un firewall, generada por código que nunca llega a tu gestión de CORS. Arregla primero el estado y luego asegúrate de que las respuestas de error también llevan la cabecera, para que el próximo fallo muestre su causa real.
The 'Access-Control-Allow-Origin' header contains multiple values '*, *', but only one is allowed. Dos capas añaden la cabecera: normalmente la aplicación y un proxy o CDN delante. Deja solo una.
Response to preflight request doesn't pass access control check: It does not have HTTP ok status. La petición OPTIONS recibió un 401, 403, 404 o 405. La causa habitual es una autenticación que se ejecuta antes que CORS: un preflight nunca lleva tu cabecera Authorization, así que debe responderse antes de cualquier comprobación de login.
Redirect is not allowed for a preflight request. La URL redirige: de http a https, una barra final que falta, otro host. Llama directamente a la URL final.
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'. La página envía cookies y el servidor responde *. Devuelve el origen exacto y añade Access-Control-Allow-Credentials: true, o deja de enviar credenciales.
Request header field authorization is not allowed by Access-Control-Allow-Headers in preflight response. Añade esa cabecera a Access-Control-Allow-Headers.
Un origen null significa que la página se abrió desde file://; sírvela desde un servidor web local. Y una "solución" que conviene evitar: mode: 'no-cors' silencia el error dándole a tu código una respuesta opaca que no puede leer. La petición sale y tu script no recibe nada.
CORS detrás de un CDN: caché y Vary: Origin
Una caché guarda una respuesta por URL y se la sirve a todo el que pide esa URL. Las cabeceras CORS que cambian según la petición rompen esa suposición de dos maneras.
Si el origen añade Access-Control-Allow-Origin solo cuando la petición trae Origin, la primera petición decide qué se guarda. Cuando esa primera petición vino de una etiqueta <img> normal o de una visita directa, la copia en caché no tiene cabecera CORS y todos los fetch cross-origin posteriores fallan hasta que la copia caduca. El error parece aleatorio porque depende de quién llegó primero.
Si el origen devuelve el origen que pregunta, la copia en caché nombra a un solo origen, y un segundo sitio que lee la misma URL recibe una respuesta dirigida a otro.
Hay dos diseños limpios. Para archivos públicos como fuentes, imágenes, scripts y JSON público, envía la misma cabecera a todos: * o el origen de tu único sitio. Así cada copia en caché es correcta para cualquier visitante y la tasa de aciertos no se toca. Cuando la respuesta tiene que variar según el origen, envía Vary: Origin en todas las respuestas de esa URL para que la caché mantenga separadas las variantes. Eso cuesta algo de tasa de aciertos, así que úsalo solo en las rutas que lo necesitan.
Los navegadores también cachean. Una imagen cargada primero con una etiqueta <img> normal y pedida después con el atributo crossorigin puede salir de la caché del navegador sin la cabecera. Los mismos dos diseños resuelven ese caso.
Cabeceras CORS en CDN.com.tr
Para los archivos que se sirven a través del CDN, el edge puede añadir la cabecera por sí mismo. En el panel, abre Reglas de entrega, edita la regla que cubre la ruta (por ejemplo /fonts/ o /static/) y escribe una cabecera por línea en Encabezados personalizados con el formato Nombre-Cabecera valor (en la página de reglas con pestañas: Cabeceras → Añadir cabeceras de respuesta). Un valor con espacios va entre comillas dobles.
El edge añade estas cabeceras a las respuestas correctas y de redirección (2xx y 3xx), también a las servidas desde caché, desde el momento en que se publica la regla. El panel y el edge rechazan ;, { y } en una línea de cabecera; una cabecera CORS nunca los necesita, porque Access-Control-Allow-Origin admite un solo valor.
Si tu origen ya envía una cabecera Access-Control-* para esos archivos, selecciona el mismo nombre en Ocultar Encabezados dentro de esa regla. Si no, el navegador recibe dos valores y rechaza los dos.
Dos cosas siguen en tu origen. La primera, los preflight: el edge pasa las peticiones OPTIONS a tu servidor y nunca las cachea, así que una API que necesita preflight los responde ella misma. Las peticiones GET normales de fuentes, imágenes y JSON público no necesitan preflight, y con la cabecera de la regla les basta. La segunda, las respuestas que varían según el origen: devolver uno de varios orígenes permitidos, como exigen las peticiones con credenciales, es decisión de tu aplicación. El edge mantiene separadas esas variantes cuando tu origen envía Vary: Origin, siempre que la regla no esté configurada para ignorar Vary. En una regla con optimización de imágenes, las imágenes JPEG y PNG se sirven a través del optimizador, que pone su propio Vary; dales a las imágenes una cabecera fija.
Si el WAF de todo el sitio está activo, OPTIONS está por defecto entre sus métodos permitidos; PUT, PATCH y DELETE hay que marcarlos en Métodos HTTP permitidos. Una petición que el edge rechaza recibe un 403 sin cabeceras CORS, y el navegador lo muestra como error CORS. La cabecera X-MT-Blocked-By: cdn-edge-security de ese 403 te dice que quien dijo que no fue el edge, no tu aplicación.
Encabezados personalizados en una regla para /fonts/ o /static/
Access-Control-Allow-Origin *
Access-Control-Expose-Headers "Content-Length, ETag"
CORS en buckets de object storage
Los archivos que un navegador sube directamente a un bucket, por ejemplo con un PUT presigned desde una aplicación web, los responde el servicio de almacenamiento y no tu aplicación, así que el bucket necesita sus propias reglas CORS.
El object storage de CDN.com.tr las acepta mediante la llamada estándar de S3, PutBucketCors, en https://s3.cdn.com.tr, y responde él mismo a los preflight del navegador. Sirve cualquier herramienta S3 capaz de fijar CORS en un bucket; con la AWS CLI es un comando y un pequeño archivo JSON.
Para todo lo que escribe, enumera orígenes exactos en lugar de *, y expón ETag: las librerías de subida del navegador lo leen para completar las subidas multipart. CORS tampoco hace público un bucket. Un bucket privado sigue respondiendo 403 a las lecturas anónimas; CORS solo decide si una página puede leer una respuesta que ya tenía permiso para obtener.
CORS de un bucket con la AWS CLI
# cors.json
{
"CORSRules": [{
"AllowedOrigins": ["https://app.example.com"],
"AllowedMethods": ["GET", "PUT"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}]
}
aws s3api put-bucket-cors --bucket my-bucket \
--cors-configuration file://cors.json \
--endpoint-url https://s3.cdn.com.tr
aws s3api get-bucket-cors --bucket my-bucket \
--endpoint-url https://s3.cdn.com.tr
Probar CORS desde la línea de comandos
curl no aplica CORS, y por eso es la herramienta adecuada para ver qué dice de verdad un servidor. Envía tú mismo la cabecera Origin y lee las cabeceras Access-Control-* que vuelven; para un preflight, envía OPTIONS con las dos cabeceras que añadiría el navegador.
Comprueba tres cosas: un estado 2xx en el preflight, exactamente un Access-Control-Allow-Origin y Vary: Origin allí donde el valor cambia según el origen. Después repite la petición a través del CDN y compara. En CDN.com.tr la cabecera X-Proxy-Cache-MT indica si la respuesta salió de la caché; si la respuesta en caché difiere de la primera, está actuando el problema de caché descrito arriba.
# a simple request: what does the edge answer for this origin?
curl -sI https://cdn.example.com/fonts/inter.woff2 \
-H 'Origin: https://www.example.com' \
| grep -i -E 'access-control|vary|x-proxy-cache'
# a preflight, as the browser would send it
curl -si -X OPTIONS https://api.example.com/v1/orders \
-H 'Origin: https://shop.example.com' \
-H 'Access-Control-Request-Method: PUT' \
-H 'Access-Control-Request-Headers: authorization, content-type'
Preguntas frecuentes sobre CORS
¿CORS protege mi API?
No. CORS protege los navegadores de tus usuarios frente a páginas que intentan leer datos en su nombre. No hace nada contra un script, un servidor o curl que llamen a tu API directamente, y las peticiones simples llegan a tu servidor aunque el navegador luego oculte la respuesta. Tu API sigue necesitando su propia autenticación y autorización.
¿Es seguro Access-Control-Allow-Origin: *?
Para recursos públicos que no requieren login, como fuentes, imágenes, scripts y JSON público, sí: cualquiera podría descargarlos de todos modos. No sirve para nada que dependa de cookies o de la sesión del usuario, y los navegadores se niegan a combinarlo con credenciales.
¿Cómo permito más de un origen?
Con una lista no: la cabecera admite un solo valor. Mantén una lista de permitidos en el servidor, devuelve el Origin de la petición cuando esté en ella y envía Vary: Origin en todas las respuestas para que las cachés mantengan separadas las respuestas.
¿Por qué la petición funciona en Postman o curl y falla en el navegador?
Porque solo los navegadores aplican CORS. Postman y curl envían la petición y muestran lo que vuelva; el navegador comprueba las cabeceras Access-Control-* antes de dejar que tu JavaScript vea la respuesta.
¿Por qué el error CORS aparece solo a veces?
Normalmente por una caché. Si la respuesta cambia según la cabecera Origin pero no dice Vary: Origin, la petición que llegó primero a la caché decide lo que reciben todos los demás. Envía una cabecera fija para los archivos públicos, o Vary: Origin donde la respuesta deba variar.
¿Cómo activo CORS en CDN.com.tr?
Para los archivos que sirve el CDN, añade Access-Control-Allow-Origin en los Encabezados personalizados de la regla de entrega; los preflight de tu API y las respuestas que varían por origen salen de tu origen. Para los buckets de object storage, fija las reglas CORS con la llamada S3 PutBucketCors en https://s3.cdn.com.tr.