Alex HerreraDesarrollador de software · Enfoque en ciberseguridad
EN
← Volver al blog

Seguridad web

Qué es CORS y cómo resolver el error «blocked by CORS policy»

Por qué el navegador bloquea una respuesta que curl sí lee, qué protege CORS y cómo configurarlo con seguridad.

  • Seguridad
Publicado
Actualizado
Lectura
19 min de lectura

Una página servida en http://localhost:5173 pide datos a una API en http://localhost:3000. Con curl, la API responde 200 con el cuerpo. Desde la página, Chrome muestra este error en la consola:

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

El log de la API registra la petición en los dos casos. El servidor respondió, y fue el navegador el que no entregó la respuesta a la página. Ese bloqueo lo impone la política de mismo origen, y CORS es el protocolo con el que el servidor la relaja.

Las mediciones se hicieron el 27 de septiembre de 2026 con Chrome 153.0.8010.54 en modo headless, Node.js 24.19.0, curl 8.12.1, Express 5.2.1 y Vite 8.3.1. Todo el código está reconstruido para el artículo.

1. Qué es el mismo origen y qué relaja CORS

El origen de una URL es la combinación de su esquema, su host y su puerto. Dos URL tienen el mismo origen solo si coinciden las tres partes1. Comparadas con http://localhost:5173:

URL ¿Mismo origen? Motivo
http://localhost:5173/api/perfil Sí Solo cambia la ruta
http://localhost:3000 No Otro puerto
https://localhost:5173 No Otro esquema
http://127.0.0.1:5173 No Otro host, aunque apunte a la misma máquina

La política de mismo origen impide que un script lea la respuesta de otro origen1. Sin ella, cualquier web abierta en el navegador podría leer los datos de otra en la que usted tiene la sesión iniciada.

CORS (Cross-Origin Resource Sharing) permite a un servidor indicar, con cabeceras HTTP, qué otros orígenes pueden leer sus respuestas2. El navegador lee esas cabeceras y decide si entrega la respuesta al script.

Quien aplica CORS es el navegador. curl, Postman y el código de un servidor no aplican la política de mismo origen. Leen la respuesta tenga o no cabeceras CORS3. Por eso la misma petición funciona en curl y falla en la página.

2. Qué hace el navegador con una petición a otro origen

La API de ejemplo usa solo el módulo http de Node.js y escribe una línea de log por petición. En este primer estado no envía ninguna cabecera CORS:

// api.mjs
import http from 'node:http';

http.createServer((req, res) => {
  console.log(`${req.method} ${req.url} Origin=${req.headers.origin ?? '-'} Cookie=${req.headers.cookie ?? '-'}`);
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end('{"mensaje":"hola"}');
}).listen(3000);

Una página en http://localhost:5173 envía una petición GET y otra POST con texto plano. En la medición, cada una se lanzó por separado:

await fetch('http://localhost:3000/');
await fetch('http://localhost:3000/pedido', {
  method: 'POST',
  headers: { 'Content-Type': 'text/plain' },
  body: 'hola'
});

Las dos promesas se rechazan con TypeError: Failed to fetch, y la consola muestra el error del principio con la URL de cada petición. El log de la API, en cambio, registra las dos peticiones:

GET / Origin=http://localhost:5173 Cookie=-
POST /pedido Origin=http://localhost:5173 Cookie=-

El navegador envió las peticiones, añadió la cabecera Origin con el origen de la página y esperó la respuesta. Después buscó en ella Access-Control-Allow-Origin, no la encontró y no se la entregó al script.

Esto tiene una consecuencia directa. Si la ruta /pedido guardara un pedido, el pedido quedaría guardado aunque la página no pueda leer la respuesta. El punto 7 vuelve sobre ello.

Cómo comprobarlo. Busque la petición en el log del servidor, además de en la consola del navegador. Si la línea aparece, la petición llegó y el bloqueo ocurrió en el navegador.

3. Qué es la petición previa y cuándo se envía

Una petición previa (preflight) es una petición OPTIONS que el navegador envía antes de la petición real. Pregunta al servidor si acepta el método y las cabeceras que la página quiere usar. El navegador la envía cuando la petición no es simple4. Las causas más frecuentes son tres:

  • usa un método distinto de GET, HEAD o POST;
  • añade una cabecera fuera de la lista permitida, como Authorization;
  • lleva un Content-Type distinto de application/x-www-form-urlencoded, multipart/form-data o text/plain.

Una petición con JSON cumple la tercera:

await fetch('http://localhost:3000/pedido', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: '{"a":1}'
});

Contra la API anterior, el log registra solo la petición previa. El POST no llega a enviarse:

OPTIONS /pedido Origin=http://localhost:5173 Cookie=-

Chrome indica en el error que falló la petición previa:

Access to fetch at 'http://localhost:3000/pedido' from origin 'http://localhost:5173' 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.

La petición previa lleva la cabecera Access-Control-Request-Method y, si la petición lleva cabeceras fuera de la lista permitida, Access-Control-Request-Headers. Un Content-Type como application/json cuenta como una de ellas. El servidor contesta con Access-Control-Allow-Methods y Access-Control-Allow-Headers, y puede añadir Access-Control-Max-Age para que el navegador guarde la respuesta durante esos segundos2. Con la configuración del punto 4, el log registra las dos peticiones en orden:

OPTIONS /pedido Origin=http://localhost:5173 Cookie=-
POST /pedido Origin=http://localhost:5173 Cookie=-

4. Cómo resolver el error en el servidor

El error desaparece si la página y la API comparten origen, porque entonces no hay ninguna petición a otro origen. En desarrollo, el servidor de Vite puede reenviar a la API las peticiones que empiezan por /api5:

// vite.config.js
import { defineConfig } from 'vite';

export default defineConfig({
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:3000',
        rewrite: (path) => path.replace(/^\/api/, '')
      }
    }
  }
});

La página pide /api/... a su propio origen, http://localhost:5173, y Vite reenvía la petición a http://localhost:3000. En la medición, la API sin ninguna cabecera CORS respondió 200 y la página recibió la respuesta sin errores de CORS. El proxy de Vite funciona en el servidor de desarrollo y en vite preview, no en el resultado de la compilación. En producción se consigue lo mismo sirviendo la página y la API desde el mismo dominio, detrás de un proxy inverso.

Cuando la página y la API tienen que estar en orígenes distintos, el error se resuelve en el servidor que responde, no en la página que pregunta. Access-Control-Allow-Origin admite un solo origen, * o null, pero no una lista2. Para aceptar varios orígenes, el servidor compara el Origin de cada petición con su propia lista y devuelve el que coincide.

// api.mjs, con lista de orígenes permitidos
import http from 'node:http';

const PERMITIDOS = new Set(
  (process.env.ORIGENES_PERMITIDOS ?? 'http://localhost:5173').split(',')
);

http.createServer((req, res) => {
  const origen = req.headers.origin;
  res.setHeader('Vary', 'Origin');
  if (origen && PERMITIDOS.has(origen)) {
    res.setHeader('Access-Control-Allow-Origin', origen);
  }
  if (req.method === 'OPTIONS') {
    if (origen && PERMITIDOS.has(origen)) {
      res.setHeader('Access-Control-Allow-Methods', 'GET, POST');
      res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
      res.setHeader('Access-Control-Max-Age', '600');
    }
    res.writeHead(204).end();
    return;
  }
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end('{"mensaje":"hola"}');
}).listen(3000);

Cómo hacerlo.

  1. Defina los orígenes permitidos en una variable de entorno, separados por comas, con un valor por entorno. En desarrollo, el del servidor del frontend; en producción, el dominio público.
  2. Compare el Origin recibido con la lista por igualdad exacta de la cadena completa. Set.has compara así; includes sobre un texto o una expresión regular sin anclar, no.
  3. Si el origen está en la lista, devuélvalo en Access-Control-Allow-Origin. Si no está, no envíe ninguna cabecera Access-Control-*.
  4. Añada Vary: Origin a todas las respuestas. Indica a las cachés intermedias que la respuesta cambia según el origen, y evita que sirvan a un origen la cabecera calculada para otro2.
  5. Conteste la petición previa solo con los métodos y las cabeceras que usa el frontend.

En Express, el paquete cors 2.8.6 hace lo mismo con su opción origin, que acepta una lista: cors({ origin: ['http://localhost:5173'] })3. En la medición, con esa lista devolvió el origen permitido, no devolvió nada a los demás y añadió Vary: Origin por su cuenta.

Cómo comprobarlo. Envíe la misma petición con un origen permitido y con otro que no lo esté:

curl -s -i -H "Origin: http://localhost:5173" http://localhost:3000/
curl -s -i -H "Origin: https://atacante.example" http://localhost:3000/

La primera respuesta trae Access-Control-Allow-Origin: http://localhost:5173 y Vary: Origin. La segunda trae solo Vary: Origin.

Cuando el error no está en la configuración CORS. El mismo mensaje aparece cuando la respuesta sale sin las cabeceras CORS, aunque la configuración sea correcta. En la medición, con la lista de orígenes activa, dos casos dieron el error:

  1. Una ruta que falla y responde 500 antes de añadir las cabeceras. Chrome mostró «No ‘Access-Control-Allow-Origin’ header is present on the requested resource», el mismo texto que sin configurar CORS.
  2. Una comprobación de autenticación que se ejecuta antes que CORS. La petición previa nunca lleva credenciales4, así que la comprobación respondió 401. Chrome indicó «Response to preflight request doesn’t pass access control check».

El navegador no da al script el detalle de una petición bloqueada por CORS6. Para ver el estado real, repita la petición con curl y la misma cabecera Origin:

curl -s -i -H "Origin: http://localhost:5173" http://localhost:3000/falla
# HTTP/1.1 500 Internal Server Error
curl -s -i -X OPTIONS -H "Origin: http://localhost:5173" \
  -H "Access-Control-Request-Method: GET" \
  -H "Access-Control-Request-Headers: authorization" http://localhost:3000/privado
# HTTP/1.1 401 Unauthorized

Si curl muestra un error, corríjalo antes de tocar CORS. En el caso de la autenticación, deje pasar las peticiones OPTIONS antes de comprobar la sesión. En Express, eso equivale a registrar app.use(cors(...)) antes que el middleware de autenticación.

5. Qué no resuelve mode: 'no-cors'

mode: 'no-cors' hace desaparecer el error, pero no da acceso a la respuesta. La petición se envía y el navegador entrega a la página una respuesta opaca7:

const r = await fetch('http://localhost:3000/', { mode: 'no-cors' });
// r.type === 'opaque', r.status === 0, await r.text() === ''

El tipo es opaque, el estado es 0 y text() devuelve una cadena vacía, aunque el servidor respondió 200 con JSON. El código no puede leer ni el estado ni los datos. Este modo sirve para enviar una petición cuyo resultado no importa, no para leer una API.

Una extensión del navegador que desactiva CORS tampoco lo resuelve. Cambia solo el navegador de quien la instala, y los usuarios de la aplicación seguirán viendo el error.

Cómo hacerlo. Quite mode: 'no-cors' del cliente y configure el servidor como en el punto 4.

6. Cómo permitir cookies entre orígenes sin abrir la API

Por defecto, fetch no envía cookies a otro origen. Para enviarlas, la página usa credentials: 'include'. Entonces el servidor tiene que responder con un origen concreto en Access-Control-Allow-Origin y con Access-Control-Allow-Credentials: true2. Con *, Chrome rechaza la respuesta:

Access to fetch at 'http://localhost:3000/perfil' from origin 'http://localhost:8080' 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'.

La corrección que se ve a menudo es devolver el Origin recibido, sea cual sea, junto con Allow-Credentials. En Express, cors({ origin: true, credentials: true }) hace exactamente eso3, y en la medición devolvió https://atacante.example a quien lo envió. El error desaparece, pero la API queda abierta a cualquier origen:

// Refleja cualquier origen con credenciales: no lo use
res.setHeader('Access-Control-Allow-Origin', req.headers.origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');

Para medirlo, la API añade dos rutas: /login, que fija la cookie sesion con SameSite=None; Secure, y /perfil, que devuelve los datos de esa sesión. La víctima inicia sesión en la API. Después abre una página de otro origen, que ejecuta esto:

const r = await fetch('http://localhost:3000/perfil', { credentials: 'include' });
await r.text();
// {"email":"ana@example.com","saldo":1200}

La página de otro origen leyó los datos de la víctima con la sesión de la víctima. PortSwigger lo describió en 2016 como la vulnerabilidad de CORS más común, y documentó en aplicaciones reales otros dos fallos parecidos8.

El primero es aceptar el origen null. Un documento en un iframe con sandbox envía Origin: null. En la medición, un iframe con sandbox="allow-scripts" leyó la respuesta de la API que reflejaba el origen.

El segundo es comparar el origen solo por el principio o por el final. Una comprobación con endsWith('ejemplo.com') acepta https://atacante-ejemplo.com, y una con startsWith('https://ejemplo.com') acepta https://ejemplo.com.atacante.example.

Los tres encajan en CWE-942 de MITRE, una política entre dominios que incluye dominios no fiables9.

El atributo SameSite de la cookie decide si esta llega en un fetch desde otro sitio10. Un sitio es el esquema más el dominio registrable, sin el puerto11. Por eso localhost:8080 y localhost:3000 son el mismo sitio, y 127.0.0.1:8080 es otro. Con la API que refleja el origen:

Página ¿Mismo sitio que la API? Cookie Resultado
http://localhost:8080 Sí SameSite=None; Secure Lee los datos
http://localhost:8080 Sí SameSite=Lax Lee los datos
http://127.0.0.1:8080 No SameSite=Lax 401: la cookie no se envía
http://127.0.0.1:8080 No SameSite=None; Secure Lee los datos

La última fila depende de que el navegador admita cookies de terceros, como Chrome 153 en su configuración por defecto.

Chrome trata como Lax una cookie sin SameSite desde la versión 8012. SameSite=Lax limita el ataque a páginas del mismo sitio, como otro subdominio. Una API que necesita cookies entre sitios usa SameSite=None, y entonces solo la lista de orígenes permitidos la protege.

Cómo hacerlo.

  1. Use la lista del punto 4 y añada Access-Control-Allow-Credentials: true solo si la API usa cookies entre orígenes.
  2. No acepte null en la lista.
  3. Si necesita aceptar subdominios, use una expresión regular anclada con ^ y $ y el punto escapado, como ^https://[a-z0-9-]+\.ejemplo\.com$. El ejemplo del README de cors, /example\.com$/, solo está anclado al final. En la medición aceptó https://evilexample.com3.

Cómo comprobarlo. Envíe un origen ajeno y el origen null, y compruebe que ninguna de las dos respuestas trae Access-Control-Allow-Origin:

curl -s -i -H "Origin: https://atacante.example" http://localhost:3000/perfil
curl -s -i -H "Origin: null" http://localhost:3000/perfil

7. Qué no protege CORS

CORS decide qué orígenes pueden leer una respuesta en el navegador. No decide quién puede enviar una petición al servidor.

Los clientes que no son navegadores no aplican CORS. curl, un script o el servidor de un atacante leen la respuesta con cualquier configuración. Por eso la autenticación y la autorización se comprueban en el servidor.

Las peticiones simples, además, llegan al servidor igualmente. Con la lista del punto 4 activa, la página de localhost:8080 no pudo leer /perfil. Aun así, el log registró la petición con la cookie de la víctima: GET /perfil Origin=http://localhost:8080 Cookie=sesion=abc123.

En eso se basa CSRF (Cross-Site Request Forgery). Otra web envía una petición con efecto usando la sesión de la víctima, aunque no pueda leer la respuesta. CORS no la impide. La defensa es un token anti-CSRF en las rutas que cambian datos, o la protección que ya traiga el framework. Si la aplicación solo admite navegadores modernos, OWASP acepta también rechazar las peticiones que llegan con Sec-Fetch-Site: cross-site, comprobando además Origin o Referer para los navegadores que no la envían. SameSite en la cookie de sesión añade una capa más, pero no sustituye al token13.

8. Cómo comprobar la configuración CORS de un proyecto

La guía de pruebas de OWASP pide localizar las rutas que responden con CORS y marcar el comodín y el origen devuelto sin comprobar14. Estas cuatro peticiones cubren los fallos de los puntos 4 a 6. Envíelas contra cada ruta que devuelva datos de un usuario:

curl -s -i -H "Origin: https://atacante.example" <url>
curl -s -i -H "Origin: null" <url>
curl -s -i -H "Origin: https://<dominio permitido>.atacante.example" <url>
curl -s -i -X OPTIONS -H "Origin: <origen permitido>" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: content-type" <url>
Respuesta Qué indica
Access-Control-Allow-Origin repite el origen ajeno, con Allow-Credentials: true El origen se refleja: cualquier web lee la API con la sesión del usuario
Access-Control-Allow-Origin: null Se acepta el origen null
La tercera petición recibe su origen de vuelta La comparación mira el principio del origen o busca una subcadena, no el origen completo
Access-Control-Allow-Origin: * en una ruta con datos privados Cualquier web lee la ruta. Sin cookies, solo es seguro en datos públicos
Falta Vary: Origin cuando la cabecera cambia según el origen Una caché puede servir la cabecera de un origen a otro
Access-Control-Allow-Headers no incluye una cabecera que la página usa, como content-type o authorization El navegador no enviará esa petición
La petición previa no trae Access-Control-Allow-Methods El navegador no enviará PUT, PATCH ni DELETE

En la API de ejemplo con el origen reflejado, las tres primeras peticiones devolvieron su propio origen con Access-Control-Allow-Credentials: true. Con la lista del punto 4, ninguna trajo Access-Control-Allow-Origin.

9. Cómo probar cada configuración desde esta página

El simulador reúne los casos de los puntos 2 a 7. Pulse un caso y lea primero la respuesta a «¿Puede la página leer la respuesta?». Debajo aparece lo que envía el navegador y lo que responde el servidor en cada petición.

En «Opciones» puede cambiar cada parte por separado, como la web que hace la petición o cómo responde el servidor.

Simulador de CORS

Su frontend está en http://localhost:5173 y su API en http://localhost:3000. La API devuelve el perfil o guarda un pedido solo si recibe la cookie de sesión.

Elija un caso

El servidor recibe la petición y responde 200, pero no envía cabeceras CORS. El navegador no deja leer la respuesta.

Opciones

10. Cómo configurar CORS en su proyecto con un prompt

Los puntos anteriores se pueden aplicar a mano. También puede hacerlo un agente de programación con un prompt. Cópielo con el botón «Copiar prompt» del panel «Configurar CORS» y péguelo en una sesión abierta en la raíz del proyecto. El agente sigue siete fases:

  1. Reconoce el proyecto sin escribir nada. Busca dónde se fija CORS, en el código o en el proxy, y qué orígenes llaman hoy a la API. También averigua si autentica con cookies o con Authorization.
  2. Diagnostica con las peticiones del punto 8 y clasifica lo que encuentra, del fallo más grave al más leve.
  3. Propone y espera. Presenta los fallos, la lista de orígenes por entorno y el cambio exacto, y se detiene hasta que usted confirma.
  4. Aplica el cambio del punto 4, con la lista en una variable de entorno. No toca otra lógica de la aplicación.
  5. Añade una prueba con un origen permitido, uno ajeno y la petición previa. Si la configuración anterior era insegura, comprueba primero que la prueba falla contra ella.
  6. Repite las peticiones del punto 8 y pega las cabeceras de antes y de después.
  7. Entrega un informe con lo que cambió, lo que no pudo comprobar y lo que CORS no cubre, como CSRF.

El prompt se ejecutó una vez, como demostración, con Claude Code 2.1.283 y la versión española del prompt. El proyecto de ejemplo era una API con Express 5.2.1 y cors({ origin: true, credentials: true }). Tardó 6 minutos sin contar la espera de la confirmación: 3 hasta la propuesta y 3 para aplicar, probar y comprobar.

El agente encontró cuatro fallos: el origen reflejado con credenciales, el origen null, un origen con otro dominio detrás y una petición previa sin límites. La prueba nueva falló en 2 de sus 3 casos con la configuración anterior y pasó con el cambio. También avisó de que npm start no cargaba la variable de entorno, y no tocó package.json sin confirmación.

Una ejecución sobre un proyecto preparado muestra cómo trabaja el prompt, pero no mide cuántos fallos encuentra en un proyecto real. Revise el informe como revisaría el de una persona.

Cómo comprobarlo. git diff muestra solo la configuración de CORS, la prueba nueva y el archivo de ejemplo de entorno. La prueba pasa con la orden de pruebas del proyecto. Las peticiones del punto 8 con un origen ajeno no traen Access-Control-Allow-Origin.

Por dónde seguir

CORS decide quién lee una respuesta en el navegador, y el servidor sigue teniendo que decidir quién puede hacer cada cosa. Ocho cosas que revisar en el código que genera la IA recoge esas comprobaciones de permisos, con su corrección.

Para que un agente aplique estas reglas en cada cambio sin depender de que alguien las recuerde, lea cómo pedirle a la IA código seguro.

CORS es una de las diez capas de Cómo proteger una página web, que enlaza el artículo de cada capa.

Referencias

  1. MDN. Same-origin policydeveloper.mozilla.org↩
  2. WHATWG. Fetch Standard, CORS protocolfetch.spec.whatwg.org↩
  3. Express. cors, READMEgithub.com↩
  4. MDN. Cross-Origin Resource Sharing (CORS)developer.mozilla.org↩
  5. Vite. Server Options, server.proxyvite.dev↩
  6. MDN. CORS errorsdeveloper.mozilla.org↩
  7. MDN. RequestInit, modedeveloper.mozilla.org↩
  8. PortSwigger Research. Exploiting CORS misconfigurations for Bitcoins and bountiesportswigger.net↩
  9. MITRE. CWE-942: Permissive Cross-domain Security Policy with Untrusted Domainscwe.mitre.org↩
  10. MDN. Set-Cookie, SameSitedeveloper.mozilla.org↩
  11. MDN. Sitedeveloper.mozilla.org↩
  12. The Chromium Projects. SameSite Updateschromium.org↩
  13. OWASP. Cross-Site Request Forgery Prevention Cheat Sheetcheatsheetseries.owasp.org↩
  14. OWASP. Web Security Testing Guide, WSTG-CLNT-07 Testing Cross Origin Resource Sharingwstg.owasp.org↩