Envías un nuevo frontend, abres la consola y ahí está: un error CORS en rojo que te dice que la solicitud fue "bloqueada por la política CORS". Tu API funciona bien en Apidog o curl, pero el navegador se niega a entregar la respuesta a tu JavaScript. ¿Frustrante? Sí. ¿Misterioso? No, una vez que sabes dónde reside el error.
Aquí está el hecho fundamental que la mayoría de los tutoriales ocultan: un error CORS es aplicado por el navegador pero causado por el servidor. El navegador bloquea la respuesta porque tu servidor no envió los encabezados Access-Control-Allow-Origin correctos. Por lo tanto, la solución casi siempre se encuentra en la configuración del servidor, no en tu código frontend.
Esta guía explica qué hace CORS, cómo funciona la solicitud "preflight", los seis mensajes de error CORS más comunes con la solución exacta para cada uno, y configuraciones funcionales para Express, Spring Boot y Nginx. También verás cómo depurar fuera del navegador, que es la forma más rápida de distinguir entre "servidor mal configurado" y "navegador bloqueado".
Qué es un error CORS (y qué no es)
CORS significa Cross-Origin Resource Sharing (Compartir Recursos de Origen Cruzado). Por defecto, los navegadores aplican la política del mismo origen: JavaScript ejecutándose en https://app.example.com no puede leer respuestas de https://api.example.com, porque el esquema, host o puerto difieren. CORS es el mecanismo que usan los servidores para relajar esta regla intencionadamente. Los detalles completos se encuentran en la documentación CORS de MDN, y el algoritmo subyacente está definido en la especificación Fetch.
Tres puntos aclaran la mayor parte de la confusión:
- El navegador lo aplica. Solo los navegadores realizan comprobaciones CORS. Las llamadas de servidor a servidor, curl y los clientes de API de escritorio lo ignoran por completo.
- El servidor lo configura. El navegador decide basándose en los encabezados de respuesta que envía tu servidor. Sin encabezados, no hay acceso.
- La solicitud generalmente llega al servidor. Para solicitudes simples, el servidor procesa todo y responde. Luego, el navegador retiene la respuesta de tu JavaScript. CORS no es un muro de seguridad alrededor de tu API; protege a los usuarios de páginas maliciosas que leen datos de origen cruzado con sus cookies.
Así que cuando veas un error CORS, no busques una solución alternativa en el frontend. Lee el mensaje de error y luego corrige el encabezado faltante o incorrecto en el servidor.
Anatomía de la solicitud preflight
Antes de ciertas solicitudes de origen cruzado, el navegador envía un explorador: una solicitud OPTIONS llamada preflight. Se activa cuando tu solicitud utiliza métodos distintos de GET, HEAD o POST, envía encabezados personalizados como Authorization, o utiliza un Content-Type como application/json.
La solicitud preflight se ve así:
OPTIONS /v1/orders HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-type
El navegador pregunta: "¿Una página en app.example.com quiere hacer un POST aquí con estos encabezados. ¿Permitido?" Una respuesta correcta del servidor:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 86400
Vary: Origin
Si falta alguna pieza, el navegador cancela la solicitud real antes de que se dispare. Tu endpoint de API nunca se ejecuta, tus registros no muestran nada más que un acceso OPTIONS, y la consola muestra un error CORS. Access-Control-Max-Age le dice al navegador que almacene en caché este veredicto (86400 segundos aquí), para que las solicitudes repetidas salten la preflight.
Ten en cuenta este baile de dos pasos. La mitad de toda la depuración de CORS se reduce a una pregunta: ¿falló la preflight o falló la solicitud real?
Los 6 errores CORS más comunes y cómo solucionar cada uno
Los navegadores escriben mensajes de error CORS sorprendentemente precisos. Compara el tuyo con la lista siguiente.
1. No hay encabezado ‘Access-Control-Allow-Origin’ presente
El clásico. Tu servidor envió una respuesta sin ningún encabezado CORS. El navegador no tuvo nada que evaluar, por lo que bloqueó el acceso.
Solución: Configura el servidor para que envíe Access-Control-Allow-Origin con el origen solicitante específico o * para APIs públicas y sin credenciales:
Access-Control-Allow-Origin: https://app.example.com
Una trampa: las respuestas de error a menudo omiten los encabezados CORS incluso cuando las respuestas de éxito los incluyen. Si tu API devuelve un 500 y el middleware solo decora los 200, la consola muestra un error CORS en lugar del error real del servidor. Asegúrate de que los encabezados CORS estén adjuntos a cada respuesta, incluidas las páginas 403 Forbidden y 500.
2. El comodín '*' no se puede usar con credenciales
El mensaje dice: "El valor del encabezado 'Access-Control-Allow-Origin' no debe ser el comodín '*' cuando el modo de credenciales de la solicitud es 'include'".
Tu frontend envía cookies o encabezados de autenticación con credentials: 'include', pero el servidor responde con Access-Control-Allow-Origin: *. La especificación Fetch prohíbe esta combinación; un comodín más credenciales permitiría que cualquier sitio en internet leyera respuestas autenticadas.
Solución: Refleja el origen exacto en lugar del comodín y añade el encabezado de credenciales:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Valida el Origin entrante contra una lista blanca antes de reflejarlo. Reflejar orígenes arbitrarios con credenciales habilitadas anula toda la protección.
3. La respuesta a la solicitud preflight no pasa la verificación de control de acceso
Tu servidor nunca manejó la solicitud OPTIONS. Quizás la ruta solo define POST, por lo que OPTIONS devuelve un 404 o 405. Tal vez un middleware de autenticación la rechazó con un 401 porque la preflight no lleva ningún token (los navegadores nunca adjuntan credenciales a las preflights).
Solución: Maneja OPTIONS explícitamente y devuelve un 2xx con el conjunto completo de encabezados CORS antes de que se ejecute la autenticación. En la mayoría de los frameworks, montar el middleware CORS primero lo resuelve. Si lo estás escribiendo a mano:
app.options('/v1/orders', (req, res) => {
res.set({
'Access-Control-Allow-Origin': 'https://app.example.com',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Authorization, Content-Type'
});
res.sendStatus(204);
});
4. El valor del encabezado no es igual al origen proporcionado
El servidor envía un encabezado Access-Control-Allow-Origin, pero nombra el origen incorrecto. Causas comunes: un origen de producción codificado mientras pruebas desde http://localhost:5173, una comparación de lista blanca que falla en http vs https, o una barra final perdida (https://app.example.com/ no es un valor de origen válido).
Solución: Compara el encabezado Origin de la solicitud con tu lista blanca exactamente, refleja la coincidencia y envía Vary: Origin para que las cachés y los CDN no sirvan el encabezado de un origen a otro:
const allowed = ['https://app.example.com', 'http://localhost:5173'];
if (allowed.includes(req.headers.origin)) {
res.set('Access-Control-Allow-Origin', req.headers.origin);
res.set('Vary', 'Origin');
}
5. El campo de encabezado de solicitud o el método no está permitido
Dos mensajes relacionados: "El campo de encabezado de solicitud authorization no está permitido por Access-Control-Allow-Headers en la respuesta preflight" y "El método PUT no está permitido por Access-Control-Allow-Methods".
La preflight tuvo éxito, pero su respuesta no cubrió lo que tu solicitud necesita. Añadiste un encabezado Authorization o un X-Request-Id, y la lista blanca del servidor nunca lo mencionó.
Solución: Extiende la respuesta preflight para incluir todos los encabezados y métodos que envía tu frontend:
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type, X-Request-Id
Los nombres de los encabezados aquí no distinguen mayúsculas y minúsculas. Los métodos distinguen mayúsculas y minúsculas y están en mayúsculas.
6. La redirección no está permitida para una solicitud preflight
La preflight alcanzó una URL que devolvía 301 o 302, y los navegadores se niegan a seguir redirecciones durante la preflight. Culpables típicos: una URL http que redirige a https, una barra final faltante que tu framework "amablemente" redirige, o una puerta de enlace que rebota /v1/orders a /v1/orders/.
Solución: Apunta tu frontend directamente a la URL final. Usa https desde el principio, coincide con la convención de barra final de tu enrutador y confirma con una llamada OPTIONS manual para verificar si el endpoint responde con un 2xx en lugar de un 3xx.
Ejemplos de configuración de servidor
Aquí tienes la configuración CORS correcta en tres stacks comunes.
Express
Usa el middleware oficial de cors en lugar de crear los encabezados a mano:
const express = require('express');
const cors = require('cors');
const app = express();
app.use(cors({
origin: ['https://app.example.com', 'http://localhost:5173'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Authorization', 'Content-Type'],
credentials: true,
maxAge: 86400
}));
Móntalo antes de tu middleware de autenticación para que las preflights nunca sean rechazadas por tokens faltantes. Los desarrolladores de Python obtienen el mismo patrón de la extensión Flask-CORS, que envuelve una lógica de encabezado idéntica para aplicaciones Flask.
Spring Boot
Configuración global a través de WebMvcConfigurer:
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/v1/**")
.allowedOrigins("https://app.example.com")
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowedHeaders("Authorization", "Content-Type")
.allowCredentials(true)
.maxAge(86400);
}
}
¿Usas Spring Security? Llama a .cors(Customizer.withDefaults()) en tu cadena de filtros de seguridad también, o la capa de seguridad bloqueará las preflights antes de que la configuración MVC las vea. Consulta la documentación de Spring CORS para ver el conjunto completo de opciones.
Nginx
Cuando Nginx finaliza las solicitudes frente a tu aplicación, responde a las preflights en el borde:
location /v1/ {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin "https://app.example.com" always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Authorization, Content-Type" always;
add_header Access-Control-Max-Age 86400 always;
return 204;
}
add_header Access-Control-Allow-Origin "https://app.example.com" always;
add_header Vary "Origin" always;
proxy_pass http://backend;
}
La bandera always es importante. Sin ella, Nginx omite las directivas add_header en las respuestas 4xx y 5xx, lo que recrea el error número uno en cada solicitud fallida. Y elige una capa para gestionar CORS: si tanto Nginx como tu aplicación añaden encabezados, los navegadores verán duplicados como Access-Control-Allow-Origin: *, * y rechazarán la respuesta.
Depurar CORS fuera del navegador con Apidog
El error de la consola te dice que el navegador bloqueó algo. No te dice qué envió el servidor. La forma más rápida de ver la verdad es sacar al navegador del bucle.
Apidog es un cliente API de escritorio, por lo que sus solicitudes no están sujetas a las comprobaciones CORS del navegador en absoluto. Esto te permite un experimento limpio: envía la misma solicitud desde Apidog que tu frontend estaba haciendo. Si tiene éxito allí, la lógica de tu API está bien y el problema es puramente la falta de encabezados CORS. Si también falla allí, tienes un error de API ordinario disfrazado de CORS, y se aplican las técnicas generales de prueba de API.
Una sesión de depuración de CORS en Apidog se ve así:
- Reproduce la solicitud real. Copia la solicitud fallida de la pestaña Red de tu navegador y recréala en Apidog con el mismo método, encabezados y cuerpo. Verifica el estado y el cuerpo. Un 500 aquí significa que CORS nunca fue tu problema.
- Prueba la preflight manualmente. Crea una nueva solicitud, establece el método en
OPTIONSy añade los encabezados que enviaría un navegador:Origin: https://app.example.com,Access-Control-Request-Method: POSTyAccess-Control-Request-Headers: authorization, content-type. Envíala. - Inspecciona los encabezados de respuesta. En el panel de respuesta, busca
Access-Control-Allow-Origin,Access-Control-Allow-MethodsyAccess-Control-Allow-Headers. Compara cada valor con lo que tu frontend necesita. Un encabezado faltante, un origen incorrecto o un estado 3xx salta a la vista de inmediato, sin necesidad de adivinar en la consola. - Verifica la solución. Después de cambiar la configuración del servidor, reenvía la misma solicitud
OPTIONSguardada y observa cómo se actualizan los encabezados. Sin volver a desplegar frontends, sin rituales de limpieza de caché.
Este flujo de trabajo también resuelve en segundos el eterno argumento "funciona en mi cliente API, falla en el navegador", el mismo enigma detrás de la pregunta sobre la prueba CORS de Postman. El cliente funciona porque omite CORS. El navegador falla porque tu servidor no ha dicho las palabras mágicas. Descarga Apidog gratis y mantén la solicitud OPTIONS guardada junto a tus pruebas de endpoint habituales; futuros "incendios" de CORS se apagan con un solo clic.
Una lista de verificación CORS de 30 segundos
Antes de registrar el error, revisa esta lista:
- ¿La respuesta que falla incluye
Access-Control-Allow-Origin? - ¿Su valor coincide exactamente con el origen de tu página (esquema, host, puerto, sin barra final)?
- ¿Usas cookies o autenticación? Confirma un origen específico más
Access-Control-Allow-Credentials: true, nunca*. - ¿
OPTIONSdevuelve un 2xx con métodos y encabezados que cubren tu solicitud? - ¿Alguna redirección en la URL de la preflight?
- ¿Las respuestas de error (401, 403, 500) llevan los mismos encabezados CORS que las respuestas de éxito?
Nueve de cada diez veces, una de esas seis líneas es tu respuesta. Verifícalo con una solicitud OPTIONS manual en Apidog, corrige la configuración del servidor y vuelve a construir.
Preguntas frecuentes
¿Por qué solo obtengo un error CORS en el navegador?
Porque solo los navegadores aplican CORS. La política del mismo origen protege a los usuarios de páginas maliciosas que leen sus datos autenticados, por lo que los navegadores verifican Access-Control-Allow-Origin en cada respuesta de origen cruzado. curl, los servicios de backend y los clientes de escritorio no tienen tal regla. Si una solicitud tiene éxito en todas partes excepto en el navegador, a tu servidor le faltan o está mal configurados los encabezados CORS; la API en sí está saludable.
¿CORS se aplica a Postman o Apidog?
No. Postman y Apidog son aplicaciones de escritorio, no páginas web que se ejecutan dentro de un entorno aislado (sandbox) del navegador, por lo que sus solicitudes omiten CORS por completo. Eso es precisamente lo que los hace útiles para la depuración de CORS: te muestran los encabezados de respuesta crudos del servidor sin el filtrado del navegador. La confusión de la prueba CORS de Postman generalmente comienza aquí; una solicitud exitosa en un cliente de escritorio no prueba nada sobre el comportamiento del navegador, pero sí aísla la capa que falla.
¿Es un error CORS una característica de seguridad o un error?
Una característica. Los errores CORS significan que el navegador está haciendo su trabajo: negándose a exponer datos de respuesta de origen cruzado a los scripts a menos que el servidor lo permita explícitamente. Deshabilitar CORS en el navegador con flags o extensiones oculta el síntoma en tu máquina mientras que todos los usuarios siguen encontrándose con el mismo problema. En su lugar, corrige los encabezados del servidor.
¿Puedo usar Access-Control-Allow-Origin: * en todas partes?
Solo para APIs públicas de solo lectura sin cookies ni autenticación. El comodín se rechaza siempre que se incluyen credenciales, y anuncia que tus datos están abiertos a cualquier origen en la web. Para cualquier cosa autenticada, mantén una lista blanca de orígenes, refleja el origen coincidente y envía Vary: Origin para que las cachés compartidas mantengan las respuestas separadas.
