Tu frontend corre en http://localhost:5173, tu API en http://localhost:4000, y la llamada fetch que funcionaba sin problemas en Postman falla en el navegador con una pared de texto rojo: Access to fetch at 'http://localhost:4000/api/users' from origin 'http://localhost:5173' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. Es muy probable que tu servidor haya procesado esa petición y devuelto un 200. El navegador se negó a entregar la respuesta a tu JavaScript.

Esa negativa es la same-origin policy. CORS es la forma en que un servidor le dice al navegador que la relaje para orígenes concretos, y cada solución de las que siguen se reduce a un header de respuesta que el navegador no recibió.

Qué bloquea realmente la same-origin policy

Un origin es la combinación de esquema, host y puerto. http://localhost:5173 y http://localhost:4000 son origins distintos porque cambia el puerto, aunque host y esquema coincidan. https://api.example.com y http://api.example.com también son origins distintos, porque cambia el esquema: uno va sobre TLS y el otro no.

La same-origin policy es una regla del navegador, no del servidor. Un script que corre en un origin no puede leer la respuesta de una petición hecha a un origin distinto a menos que ese origin lo permita explícitamente. En una GET simple, la petición sale igual y el servidor la procesa igual: la ves en los logs del servidor y en la pestaña Network con un código de estado real. Lo que el navegador retiene es el cuerpo de la respuesta. Por eso un error CORS parece un fallo del lado del cliente, mientras que el servidor no reporta nada mal.

El objetivo es impedir que una página en evil.example use tu sesión autenticada en el navegador para leer las respuestas de la API de tu banco. Sin esta regla, cualquier sitio que visites podría enviar peticiones autenticadas a cualquier otro sitio y leer las respuestas.

Qué permite CORS, y qué no protege

Cross-Origin Resource Sharing es una excepción opt-in a esa regla. El servidor añade headers de respuesta que indican qué origins pueden leer sus respuestas, y el navegador los respeta en lugar de bloquear.

El servidor no rechaza nada. Los headers CORS son instrucciones para el navegador, no control de acceso. Una petición curl, una llamada backend-to-backend o una app móvil nativa llegan al mismo endpoint sin ninguna comprobación CORS. Si tu API necesita protección, eso es autenticación y autorización.

Cuándo el navegador envía una petición preflight CORS

El navegador clasifica cada petición cross-origin en uno de dos tipos, y solo uno de ellos pide permiso antes.

Una petición es simple cuando se cumple todo esto:

  • El método es GET, HEAD o POST.
  • Los únicos headers que se establecen son los que pone el navegador automáticamente, más una lista corta permitida (Accept, Accept-Language, Content-Language, Content-Type, Range).
  • Si Content-Type está definido, su valor es application/x-www-form-urlencoded, multipart/form-data o text/plain.

Una petición simple sale directamente, y el navegador revisa los headers de respuesta para decidir si tu script recibe el body. Todo lo demás es preflighted: un PUT o DELETE, un body JSON (Content-Type: application/json), o un header personalizado como Authorization o X-API-Key. Antes de enviar la petición real, el navegador envía una petición OPTIONS preguntando al servidor qué permite:

1
2
3
4
5
OPTIONS /api/users HTTP/1.1
Host: localhost:4000
Origin: http://localhost:5173
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization

El servidor responde con lo que permite. Solo si esa respuesta cubre la petición real, el navegador la envía:

1
2
3
4
5
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400

Access-Control-Max-Age indica cuántos segundos puede el navegador cachear esa respuesta y saltarse el preflight en la siguiente petición equivalente. Una API JSON que envía un bearer token dispara un preflight en casi cada llamada, así que este header ahorra un round trip cada vez. Los navegadores lo limitan: Chromium no lo cachea más de 7200 segundos, sea cual sea el valor que envíes.

Headers de respuesta CORS y qué hace cada uno

HeaderQué hace
Access-Control-Allow-OriginEl origin autorizado a leer la respuesta: un origin literal, o * para cualquier origin. * se rechaza en peticiones con credenciales.
Access-Control-Allow-MethodsLos métodos permitidos en este recurso, devueltos en la respuesta del preflight.
Access-Control-Allow-HeadersLos headers de petición permitidos, devueltos en la respuesta del preflight. Authorization debe listarse por nombre; * no lo cubre.
Access-Control-Allow-Credentialstrue permite que el navegador exponga la respuesta a una petición con credenciales (cookies, autenticación HTTP). Requiere un origin literal, no *.
Access-Control-Expose-HeadersHeaders de respuesta, más allá de la safelist por defecto, que JavaScript puede leer.

La regla de las credenciales es la que más confunde a la gente. “Credenciales” aquí significa cookies y autenticación HTTP gestionada por el navegador, que se envían al llamar fetch(url, { credentials: 'include' }). En una petición así, el servidor debe enviar Access-Control-Allow-Credentials: true e indicar el origin exacto en Access-Control-Allow-Origin. Una respuesta con credenciales y Access-Control-Allow-Origin: * se rechaza aunque todos los demás headers sean correctos.

Un bearer token que tú mismo pones en un header Authorization no es una credencial en este sentido. Es un header personalizado: dispara un preflight y tiene que aparecer en Access-Control-Allow-Headers, pero no necesita Allow-Credentials.

Cómo arreglar errores CORS en Express

El paquete cors establece estos headers por ti.

1
npm install cors
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
const express = require("express");
const cors = require("cors");

const app = express();

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

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

app.listen(4000);

En producción sueles tener más de un frontend legítimo. Pasa un array o una función en lugar de una sola cadena:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
const allowedOrigins = ["https://app.example.com", "https://admin.example.com"];

app.use(
  cors({
    origin: (origin, callback) => {
      if (!origin || allowedOrigins.includes(origin)) {
        callback(null, true);
      } else {
        callback(new Error("Not allowed by CORS"));
      }
    },
    credentials: true,
  }),
);

Montado con app.use, cors() responde al preflight OPTIONS de cada ruta. Si lo montas solo en rutas específicas que usan un método distinto de GET, añade app.options("/api/users", cors()) para que el preflight de ese path también reciba respuesta.

Cómo habilitar CORS en ASP.NET Core

Registra una policy con nombre en Program.cs y llama a UseCors entre UseRouting y UseAuthorization. El orden importa: un preflight no lleva autenticación, así que si la autorización se ejecuta antes, rechaza la petición OPTIONS antes de que CORS pueda responder.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
var builder = WebApplication.CreateBuilder(args);

const string AllowFrontend = "_allowFrontend";

builder.Services.AddCors(options =>
{
    options.AddPolicy(AllowFrontend, policy =>
    {
        policy.WithOrigins("http://localhost:5173")
              .AllowAnyMethod()
              .AllowAnyHeader()
              .AllowCredentials();
    });
});

builder.Services.AddControllers();

var app = builder.Build();

app.UseRouting();
app.UseCors(AllowFrontend);
app.UseAuthorization();
app.MapControllers();
app.Run();

WithOrigins solo acepta origins literales, por la misma razón que el header en crudo. Combina AllowCredentials() con AllowAnyOrigin() y ASP.NET Core lanza una InvalidOperationException en lugar de enviar una respuesta que el navegador rechazaría de todos modos. Para un control por endpoint, pon [EnableCors("_allowFrontend")] en un controller o una action y llama a app.UseCors() sin nombre de policy: el middleware tiene que seguir estando en el pipeline para que el atributo funcione.

Cómo evitar CORS en desarrollo con un proxy de Vite

En tu portátil, la solución más sencilla es dejar de hacer una petición cross-origin directamente. Vite, webpack-dev-server y la mayoría de los demás dev servers pueden hacer de proxy hacia tu backend para una ruta, de modo que el navegador solo habla con un origin:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// vite.config.js
export default {
  server: {
    proxy: {
      "/api": {
        target: "http://localhost:4000",
        changeOrigin: true,
      },
    },
  },
};

Una fetch a /api/users ahora va al dev server de Vite en http://localhost:5173, que la reenvía a http://localhost:4000 y devuelve la respuesta. El navegador ve un solo origin, así que no hay nada que CORS pueda bloquear.

Esto es una comodidad de desarrollo. La versión en producción de la misma idea es un reverse proxy que sirve el frontend y la API desde un único origin público, lo que elimina por completo la necesidad de headers CORS en esa API.

Workarounds de CORS que no deberías usar

Cada uno de estos hace desaparecer el texto rojo para ti y deja el problema real, un header que tu API no está enviando, tal cual:

  • Access-Control-Allow-Origin: * en un endpoint que usa cookies. El navegador rechaza la respuesta con credenciales, así que esto ni siquiera arregla el error. Quita las cookies para que el wildcard funcione y te queda un endpoint que responde a cualquier sitio de internet sin ninguna comprobación de origin.
  • Una extensión del navegador “CORS unblock”. Modifica las respuestas dentro de tu propio navegador. El error desaparece para ti y sigue ahí para cualquier usuario real, incluido un compañero probando staging desde otra máquina.
  • Lanzar Chrome con --disable-web-security. Desactiva la same-origin policy para todas las pestañas de esa instancia del navegador, no solo tu app, mientras permanezca abierta. Una pestaña olvidada en un sitio malicioso anula exactamente la protección para la que existe la política.

Qué solución CORS usar

Si el error solo aparece en local, contra un backend en otro puerto, usa el proxy del dev server y sáltate por completo la configuración de CORS.

Si usuarios reales van a llamar a tu API cross-origin desde un dominio de frontend separado o un sitio de terceros, define Access-Control-Allow-Origin con los origins exactos que piensas servir. Añade Access-Control-Allow-Credentials solo si el navegador envía cookies. Después abre la pestaña Network de DevTools y revisa la petición OPTIONS antes de dar por hecho que funcionó: un 2xx con Allow-Origin, Allow-Methods y Allow-Headers que cubran tu petición real significa que la petición siguiente pasará.

Artículos relacionados