Error CORS: qué es «blocked by CORS policy» y su solución

Publicado:

17 min de lectura

Acar Diveroli
Autor: Acar Diveroli
Bloque ORIGIN unido por cables a fetch(), PREFLIGHT, CDN y API; solo el cable de API es azul, los otros tres están cortados

Tu frontend se ejecuta en http://localhost:5173 y tu API en http://localhost:3001. La API devuelve JSON en curl y en Postman, pero en el navegador fetch() lanza TypeError: Failed to fetch y la consola muestra: Access to fetch at 'http://localhost:3001/api/products' from origin 'http://localhost:5173' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. Sin embargo, el log de la API indica que la petición llegó y se respondió con 200.

A continuación: qué es un origen, por qué el navegador bloquea la respuesta, las peticiones simples frente a las peticiones preflight, cada mensaje de Chrome, soluciones probadas para Express, Flask y Vite, una ruta en el backend para las API que no controlas y por qué el código de scraping nunca se topa con CORS.

¿Qué es un error CORS?

Es el navegador negándose a entregar una respuesta a tu script. CORS, Cross-Origin Resource Sharing (intercambio de recursos entre orígenes), es un conjunto de cabeceras de respuesta HTTP con las que un servidor le dice al navegador qué otros orígenes pueden leer sus respuestas. El Fetch Standard lo define como un permiso que el servidor tiene que activar de forma explícita, para que los datos que están detrás de un firewall o de un inicio de sesión no se filtren por defecto a otros sitios. Sin cabeceras que coincidan, el navegador aplica su regla por defecto, la política del mismo origen (same-origin policy), y bloquea la lectura.

De ahí salen tres cosas. Los logs del servidor parecen normales, porque el error lo produjo el navegador. Tu código solo recibe un fallo genérico, TypeError: Failed to fetch desde fetch() o AxiosError: Network Error (código ERR_NETWORK) desde Axios, y el motivo solo aparece en la consola. Y la solución está en el servidor que responde, no en el código que pregunta.

¿Qué es un origen y qué bloquea la política del mismo origen?

Un origen (origin) es el esquema, el host y el puerto de una URL. Dos URL comparten origen solo cuando coinciden los tres:

  • http://localhost:5173 y http://localhost:3001: puertos distintos, orígenes distintos.
  • http://example.com y https://example.com: esquemas distintos.
  • https://example.com y https://api.example.com: hosts distintos; un subdominio es un origen propio.
  • https://example.com/shop y https://example.com/api: mismo origen; la ruta no cuenta.

La política del mismo origen sigue permitiendo que una página incruste imágenes, scripts y hojas de estilo de otros sitios, enlace a ellos y les envíe formularios. Lo que impide es la lectura: un script no puede leer una respuesta de otro origen salvo que ese origen lo permita (MDN: Same-origin policy). CORS es la forma en que el servidor concede ese permiso.

¿Cómo funciona paso a paso una petición entre orígenes?

  1. Tu página en el origen A llama a fetch() para una URL del origen B.
  2. El navegador comprueba si la petición es «simple». Si no lo es, primero envía un preflight (siguiente sección).
  3. Añade una cabecera Origin, como Origin: http://localhost:5173. Los scripts no pueden fijarla ni quitarla.
  4. El servidor ejecuta su código y responde. Lo que haya hecho el código ya ha ocurrido.
  5. El navegador compara el Access-Control-Allow-Origin de la respuesta con el origen de la página. Acepta una coincidencia exacta, o * cuando no se enviaron credenciales como las cookies.
  6. Si coinciden, tu código recibe la respuesta; si no, el navegador la descarta, la promesa se rechaza y la consola indica el motivo.

El paso 4 es el que se pasa por alto: CORS no impide que las peticiones lleguen al servidor, y curl, los scripts y otros servidores se saltan el paso 5 por completo. Protege a los visitantes, para que una página que abren no pueda leer sus datos en otros sitios con sus cookies; no protege tu API.

Peticiones simples y preflight: por qué GET funciona y POST falla

Una petición es «simple» cuando usa GET, HEAD o POST y solo cabeceras de la lista segura: Accept, Accept-Language, Content-Language, Range y Content-Type con application/x-www-form-urlencoded, multipart/form-data o text/plain. Todo lo demás pasa antes por un preflight (comprobación previa): una petición OPTIONS con Access-Control-Request-Method y Access-Control-Request-Headers, tras la cual el navegador envía la petición real solo si la respuesta lo permite (MDN: CORS). Un cuerpo JSON, una cabecera Authorization, PUT, PATCH, DELETE o una cabecera personalizada como X-Request-ID lo activan.

Enviamos un GET simple y un POST con cuerpo JSON desde una página en el puerto 5173 a una API en el puerto 3001 que no envía cabeceras CORS. La API registró:

text
[no-cors-api] GET /api/products origin=http://localhost:5173
[no-cors-api] OPTIONS /api/products origin=http://localhost:5173

El GET se respondió con 200 y los datos, y aun así el navegador lo bloqueó. Del POST solo llegó el preflight; falló, así que el POST nunca se envió. En cambio, un POST simple entre orígenes, como uno codificado como formulario, sí llega al servidor y se ejecuta aunque la página vea un error. Por eso conviene proteger esas acciones con autenticación y tokens CSRF, no con CORS.

Access-Control-Max-Age permite al navegador guardar en caché la respuesta a un preflight. El valor por defecto del Fetch Standard es de 5 segundos; Chromium limita el valor a 2 horas y Firefox a 24 horas.

¿Qué significa cada mensaje «blocked by CORS policy»?

Todos los mensajes de Chrome empiezan por Access to fetch at '<URL>' from origin '<origin>' has been blocked by CORS policy:, o por Access to XMLHttpRequest at con Axios y XMLHttpRequest. Chromium deja estos textos fijos en inglés en su código fuente, así que aparecen en inglés también en un navegador en español. El texto después de los dos puntos es la causa. Las cinco primeras filas de la tabla aparecieron palabra por palabra en nuestra prueba con un navegador basado en Chromium 152; el resto sale del código fuente de Chromium.

Mensaje tras «blocked by CORS policy:»SignificadoSolución
No 'Access-Control-Allow-Origin' header is present on the requested resource.Falta la cabecera CORS: no está configurada o respondió una página de errorPermite tu origen; comprueba el estado real
Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present…La respuesta a OPTIONS no tenía cabecera CORS; la petición real no se envióResponde a OPTIONS con cabeceras CORS
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'.Se enviaron cookies y el servidor respondió *Origen exacto más Access-Control-Allow-Credentials: true
Request header field x-debug is not allowed by Access-Control-Allow-Headers in preflight response.Una cabecera que envías no está permitidaPermítela o deja de enviarla
Method PATCH is not allowed by Access-Control-Allow-Methods in preflight response.El método no está permitidoAñade el método
The 'Access-Control-Allow-Origin' header has a value '…' that is not equal to the supplied origin.Se permite otro origen: puerto equivocado, http, una barra finalCorrige la lista de permitidos
The 'Access-Control-Allow-Origin' header contains multiple values '…', but only one is allowed.Dos capas añaden la cabecera, como la app y nginxDefínela en un solo sitio
Response to preflight request doesn't pass access control check: It does not have HTTP ok status.OPTIONS recibió 401, 404, 405 o 500Deja pasar OPTIONS antes de la autenticación
Response to preflight request doesn't pass access control check: Redirect is not allowed for a preflight request.OPTIONS se redirigió, por ejemplo a https o a una página de inicio de sesiónLlama a la URL final

Las versiones antiguas de Chrome añadían una frase que sugería mode: 'no-cors'; Chromium la eliminó en marzo de 2025 porque confundía a la gente (las preguntas frecuentes explican por qué). Firefox escribe Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at … (Reason: CORS header 'Access-Control-Allow-Origin' missing).

¿Cómo encontrar la causa real?

Abre las herramientas para desarrolladores (F12) y lee la línea de la pestaña Console (Consola en la interfaz en español): URL, origen, motivo. En la pestaña Network (Red), la petición bloqueada muestra CORS error (Error CORS) en la columna Status (Estado), y una llamada con preflight tiene una entrada OPTIONS aparte cuya columna Initiator (Iniciador) dice Preflight (Solicitud preparatoria en español). Haz clic en ella para ver las cabeceras.

El código de estado real es fácil de pasar por alto. En nuestra prueba, una puerta de enlace (gateway) respondió 502 sin cabeceras CORS: la consola solo mostró la línea «No 'Access-Control-Allow-Origin' header», mientras que curl -i contra la misma URL imprimió HTTP/1.1 502 Bad Gateway. Las páginas de error de nginx, de los balanceadores de carga o de una app caída rara vez llevan cabeceras CORS, así que las caídas parecen problemas de CORS. La directiva add_header de nginx solo se aplica a las respuestas 200, 201, 204, 206, 301, 302, 303, 304, 307 y 308, salvo que añadas el parámetro always (documentación de nginx).

¿Cómo solucionar un error CORS si la API es tuya?

Envía las cabeceras desde la propia API, para los orígenes exactos de tu frontend. Probamos cada fragmento el 6 de octubre de 2026 con Node.js 24.11, Express 5.2.1, cors 2.8.6, Vite 8.3.3, Python 3.13, Flask 3.1.3 y Flask-CORS 6.0.5.

Express

Instala con npm install express cors y pon "type": "module" en package.json.

js
// server.js: una API que permite un único frontend (Express 5, cors 2.8)
import express from "express";
import cors from "cors";

const app = express();

app.use(cors({
  origin: ["https://app.example.com", "http://localhost:5173"],
  methods: ["GET", "POST", "PUT", "DELETE"],
  allowedHeaders: ["Content-Type", "Authorization"],
  credentials: true,
  maxAge: 600,
}));
app.use(express.json());

app.get("/api/products", (req, res) => {
  res.json([{ id: 1, name: "Desk lamp" }]);
});

app.post("/api/products", (req, res) => {
  res.status(201).json({ created: req.body });
});

app.listen(3000, () => console.log("API on http://localhost:3000"));

Registrado con app.use() antes de las rutas, el middleware también responde a cada preflight OPTIONS (middleware cors de Express). Escribe los orígenes exactamente como los envía el navegador, sin barra final. Los demás orígenes no reciben cabecera Access-Control-Allow-Origin, y de eso se trata. Mantén credentials: true solo si el frontend envía cookies con credentials: "include".

Comprueba el preflight desde una terminal:

bash
curl -i -X OPTIONS http://localhost:3000/api/products \
  -H "Origin: http://localhost:5173" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: content-type"

Las líneas relevantes de nuestra ejecución:

text
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:5173
Vary: Origin
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET,POST,PUT,DELETE
Access-Control-Allow-Headers: Content-Type,Authorization
Access-Control-Max-Age: 600

Vary: Origin indica a las cachés y a las CDN que la respuesta depende de la cabecera Origin. Con Origin: https://evil.example, el mismo comando devuelve 204, pero sin la línea Access-Control-Allow-Origin.

Flask

Instala con pip install flask flask-cors.

python
# app.py: la misma API en Flask 3 con Flask-CORS 6
from flask import Flask, jsonify, request
from flask_cors import CORS

app = Flask(__name__)
CORS(
    app,
    resources={r"/api/*": {"origins": ["https://app.example.com", "http://localhost:5173"]}},
    allow_headers=["Content-Type", "Authorization"],
    supports_credentials=True,
    max_age=600,
)


@app.get("/api/products")
def list_products():
    return jsonify([{"id": 1, "name": "Desk lamp"}])


@app.post("/api/products")
def create_product():
    return jsonify({"created": request.get_json()}), 201


if __name__ == "__main__":
    app.run(port=5000)

En nuestra ejecución, el preflight devolvió 200 con el origen, los métodos y Vary: Origin, y el POST de la página devolvió 201. Pasa siempre una lista de orígenes con supports_credentials=True: sin ella, Flask-CORS 6.0.5 nos devolvió tal cual un origen arbitrario, https://evil.example, junto con Access-Control-Allow-Credentials: true.

nginx, puertas de enlace y CDN

Si un proxy inverso (reverse proxy) pone las cabeceras en lugar de la app, añade a cada add_header el parámetro always para que las respuestas de error también las lleven, y deja que solo una capa las defina. Nunca copies cualquier Origin entrante en la respuesta mientras permites credenciales: cualquier sitio web podría entonces leer los datos de tus usuarios con sus cookies. Compara los orígenes con una lista fija.

Desarrollo local: deja que el servidor de desarrollo reenvíe la API

En desarrollo, la solución más sencilla es poner la API en el mismo origen. El servidor de desarrollo de Vite puede reenviar cada ruta que empiece por /api:

js
// vite.config.js: en desarrollo, /api pasa por Vite hasta la API
import { defineConfig } from "vite";

export default defineConfig({
  server: {
    port: 5173,
    proxy: {
      "/api": {
        target: "http://localhost:3000",
        changeOrigin: true,
      },
    },
  },
});

El frontend llama a fetch("/api/products") sin host. El navegador ve su propio origen, así que no hay comprobación CORS, y Vite pasa la petición al puerto 3000; en nuestra prueba GET devolvió 200 y POST 201. changeOrigin pone el destino en la cabecera Host. webpack-dev-server ofrece lo mismo con devServer.proxy. En producción, sirve el frontend y la API bajo un mismo origen a través de tu servidor web, o mantén la configuración CORS de arriba.

Si la API no es tuya: llámala desde tu backend

Una API de terceros sin cabeceras CORS suele estar pensada para servidores, a menudo porque su clave secreta nunca debe llegar a un navegador. Añade una ruta a tu backend: el navegador llama a tu origen y tu servidor llama a la API.

js
// relay.js: tu backend llama a la API de terceros; el navegador solo habla contigo
import express from "express";

const app = express();
const PARTNER_URL = "https://api.partner.example/v1/products";

app.get("/api/partner-products", async (req, res) => {
  try {
    const r = await fetch(PARTNER_URL, {
      headers: { Authorization: `Bearer ${process.env.PARTNER_API_KEY}` },
      signal: AbortSignal.timeout(10_000),
    });
    res.status(r.status).type(r.headers.get("content-type") ?? "application/json");
    res.send(await r.text());
  } catch (err) {
    res.status(502).json({ error: "partner API unreachable", detail: err.cause?.code ?? err.name });
  }
});

app.listen(8080, () => console.log("relay on http://localhost:8080"));

Contra un sustituto local de la API del socio sin cabeceras CORS, la ruta devolvió el JSON con 200. Sirve la ruta desde el origen de la página; la clave se queda en una variable de entorno del servidor. Las cabeceras, los cuerpos y la autenticación con fetch y Axios en el lado del servidor se explican en cURL en JavaScript.

Mantén el destino fijo. Una ruta que pide cualquier cosa que llegue en ?url= convierte tu servidor en un proxy abierto que cualquiera puede usar, incluso contra direcciones internas a las que solo llega tu servidor. Los términos de uso y los límites de solicitudes de la API siguen vigentes: la llamada cambió de sitio, las reglas no.

Por qué los proxies CORS públicos son un riesgo

Un proxy CORS público pide una URL por ti y añade Access-Control-Allow-Origin: * a la respuesta. El error desaparece, pero:

  • El operador ve la URL completa, todas las cabeceras (incluidas las claves de API y los tokens) y la respuesta.
  • Puede modificar la respuesta, y tu página confiará en ella.
  • Las peticiones de tus usuarios pasan por una empresa con la que no tienes ningún acuerdo.
  • Sus límites de solicitudes y sus caídas pasan a ser los tuyos.

CORS y web scraping: por qué tu script de Python o Node.js nunca lo ve

CORS solo existe en los navegadores. Pedimos la misma API que el navegador había bloqueado, esta vez desde Node.js 24:

js
// La misma petición desde Node.js: sin navegador, sin comprobación CORS
const r = await fetch("http://localhost:3001/api/products");
console.log(r.status, r.headers.get("access-control-allow-origin"), await r.text());
text
200 null [{"id":1,"name":"Desk lamp"}]

No hay cabecera Access-Control-Allow-Origin, y Node.js lee la respuesta igualmente; Requests de Python y curl se comportan igual. Si intentas recopilar datos con fetch() en la consola del navegador o en una app de frontend, el error CORS te está diciendo que ese trabajo corresponde a código del lado del servidor. Qué lenguaje encaja mejor se compara en Web scraping: ¿JavaScript o Python?. Las reglas del sitio siguen valiendo ahí: respeta robots.txt, mantén un ritmo bajo de peticiones y usa la API oficial cuando exista.

Un proxy no arregla un error CORS. El navegador compara el origen de la página con la cabecera Access-Control-Allow-Origin, y la dirección IP no forma parte de esa comprobación, así que un proxy residencial, un proxy de centro de datos o una VPN no cambian nada. Los proxies van en el cliente HTTP de tu código del lado del servidor, como se muestra en Cómo usar un proxy en Node.js.

Para una recopilación permitida que necesita resultados locales en muchos países y ciudades, los Proxies residenciales envían las peticiones a través de conexiones domésticas, con segmentación por país y ciudad.

Para grandes volúmenes de peticiones a API públicas y páginas con poca protección, los Proxies de centro de datos ofrecen direcciones IPv4 dedicadas con tráfico sin cuota.

¿Quién se encuentra con errores CORS?

  • Desarrolladores de frontend cuya app y cuya API se ejecutan en puertos distintos.
  • Aplicaciones de una sola página que llaman a una API de terceros directamente desde el navegador.
  • Equipos que mueven una API a un subdominio como api.example.com, o a https.
  • Frontends generados con IA que llaman a una API directamente, a veces con la clave dentro de la página.

Errores comunes

  • Añadir Access-Control-Allow-Origin a la petición. Es una cabecera de respuesta; en fetch() se convierte en una cabecera personalizada que activa un preflight.
  • Usar mode: "no-cors". La respuesta vuelve opaca: estado 0, cuerpo ilegible.
  • Una extensión «allow CORS» o la seguridad web desactivada. Solo cambia tu propio navegador, y una extensión así puede leer las páginas en las que se ejecuta.
  • Responder * mientras el frontend envía cookies. El navegador rechaza esa combinación.
  • app.options("*", cors()) en Express 5. La app se detiene al arrancar con PathError [TypeError]: Missing parameter name at index 1: *; app.use(cors()) ya responde a los preflights.
  • Un middleware de autenticación que rechaza OPTIONS. Los preflights no llevan token, así que fallan con «It does not have HTTP ok status».

Guía de decisión

SituaciónQué hacer
Frontend y API en puertos distintos durante el desarrolloProxy del servidor de desarrollo (server.proxy en Vite)
Tu API, llamada desde tu frontendPermite los orígenes exactos; responde a OPTIONS
El frontend envía cookiesOrigen exacto, Access-Control-Allow-Credentials: true, Vary: Origin
Una cabecera o un método «is not allowed»Añádelo a Access-Control-Allow-Headers o -Methods
El error aparece tras un despliegue o bajo cargaComprueba el estado real con curl; always en nginx
Una API de terceros sin cabeceras CORSLlámala desde tu backend, con la clave en el servidor
Recopilar datos de otros sitiosCódigo del lado del servidor; ahí CORS no se aplica

Preguntas frecuentes

¿Por qué funcionan Postman o curl mientras el navegador muestra un error CORS?

Solo los navegadores aplican CORS. El servidor envía la misma respuesta a cualquier cliente, y solo el navegador comprueba Access-Control-Allow-Origin antes de entregársela a un script. Que curl funcione demuestra que la API está en marcha, no que un navegador pueda leer la respuesta.

¿Por qué me sale un error CORS en localhost?

Un puerto distinto es un origen distinto: localhost:5173 y localhost:3000 son dos orígenes en una misma máquina. Permite el origen del frontend en la API o usa el proxy del servidor de desarrollo. Aparte de eso, desde Chrome 142 un sitio web público que llama a localhost o a un dispositivo de tu red doméstica necesita tu permiso; si lo rechazas, la consola informa de un espacio de direcciones loopback o local denegado.

¿Usar mode: "no-cors" soluciona un error CORS?

No. La petición se envía, pero el navegador devuelve una respuesta opaca; en nuestra prueba el estado fue 0, el tipo opaque y el cuerpo ilegible. Solo sirve para peticiones cuya respuesta nunca vas a leer.

¿CORS es una medida de seguridad para mi API?

No. Impide que un sitio lea los datos de otro sitio con las cookies de un visitante, pero cualquiera puede seguir llamando a tu API con un script. Protege la API con autenticación, autorización y límites de solicitudes.

¿Un proxy o una VPN pueden arreglar un error CORS?

No. El navegador compara el origen de la página con la cabecera Access-Control-Allow-Origin de la respuesta; la dirección IP no forma parte de la comprobación.

¿Por qué solo falla mi petición POST mientras GET funciona?

Probablemente el POST necesita un preflight por un cuerpo JSON, una cabecera Authorization o una cabecera personalizada. Si la API no responde bien a la petición OPTIONS, el navegador nunca envía el POST. Busca la entrada OPTIONS en la pestaña Network (Red) o envía el preflight con curl.

En resumen

Un error CORS es el navegador negándose a entregar a tu script la respuesta de otro origen; normalmente la petición sí llegó al servidor. Lee el motivo en la consola, comprueba el estado real con curl y corrige el servidor: orígenes exactos, una respuesta a OPTIONS, los métodos y cabeceras correctos, Vary: Origin. Usa el proxy del servidor de desarrollo mientras desarrollas y tu propio backend para las API que no controlas. La recopilación de datos corresponde al código del lado del servidor, donde CORS no se aplica; los tipos de proxy para ese trabajo están en nuestra página de servicios de proxy.