Aikido

Seguridad CORS: Más allá de la configuración básica

Escrito por
Rez Moss

Todos hemos pasado por eso: envías una solicitud a una API, esperas la respuesta y, de repente, te encuentras con el error CORS en la consola de tu navegador.

Para muchos desarrolladores, el primer instinto es buscar una solución rápida: añadir Access-Control-Allow-Origin: * y seguir adelante. Sin embargo, ese enfoque pierde completamente el sentido. CORS no es solo un obstáculo de configuración más, sino uno de los mecanismos de seguridad del navegador más importantes jamás creados.

CORS, o Intercambio de Recursos de Origen Cruzado, existe para proteger a los usuarios al tiempo que permite una comunicación legítima entre dominios en aplicaciones web. Sin embargo, a menudo se malinterpreta, se configura incorrectamente o se trata como un error que hay que “saltar”.

Pero ya no es así.

En esta guía, iremos más allá de lo básico. Aprenderás:

  • Por qué existe CORS y cómo evolucionó a partir de la Política del Mismo Origen (SOP)
  • Cómo los navegadores y servidores negocian realmente el acceso de origen cruzado
  • Qué hace que algunas configuraciones de CORS fallen, incluso cuando “parecen correctas”
  • Cómo gestionar de forma segura las solicitudes preflight, las credenciales y las peculiaridades del navegador

Al final, no solo sabrás cómo configurar CORS, sino que también comprenderás por qué se comporta de esa manera y cómo diseñar tus APIs de forma segura en torno a él.

Qué es CORS (y por qué existe) 

CORS es un estándar de seguridad del navegador que define cómo las aplicaciones web de un origen pueden acceder de forma segura a recursos de otro.

Para entender la seguridad de CORS, primero debes saber por qué fue creado.

Mucho antes de que las APIs y los microservicios dominaran la web, los navegadores seguían una regla sencilla llamada la Política del Mismo Origen (SOP).

Esta política establecía que una página web solo podía enviar y recibir datos del mismo origen, es decir, el mismo protocolo, dominio y puerto.

Por ejemplo:

Tabla de comparación de mismo origen

URL A URL B ¿Mismo origen?
https://example.com/api https://example.com/users ✅ Sí
https://example.com http://example.com ❌ No (protocolo diferente)
https://example.com https://api.example.com ❌ No (host diferente)
https://example.com https://example.com:8080 ❌ No (puerto diferente)

Esta restricción tenía mucho sentido en los inicios de la web, cuando la mayoría de los sitios web eran monolíticos. Un único sitio alojaba su front-end, back-end y activos bajo un mismo dominio.

Pero a medida que la web evolucionó con APIs, microservicios e integraciones de terceros, esta misma regla se convirtió en una barrera. Los desarrolladores necesitaban que las aplicaciones front-end se comunicaran con otros dominios, como:

  • www.example.com comunicándose con api.example.com
  • Tu aplicación conectándose a una CDN o a un endpoint de analíticas
  • Clientes web llamando a APIs de terceros (como Stripe o Google Maps)

La política del mismo origen (Same-Origin Policy) se convirtió en un muro que bloqueaba las arquitecturas modernas y distribuidas.

Ahí es donde entró en juego el Intercambio de Recursos de Origen Cruzado (CORS).

En lugar de eliminar por completo las restricciones del navegador, CORS introdujo una relajación controlada de la SOP. Creó una forma segura para que navegadores y servidores se comunicaran entre dominios, de manera segura y solo cuando ambas partes estuvieran de acuerdo.

Piensa en ello así: la SOP es una puerta cerrada que no deja pasar a nadie, y CORS es la misma puerta, pero con una lista de invitados y un portero que verifica las identificaciones.

Este equilibrio entre flexibilidad y protección es lo que hace que la configuración de CORS sea crítica para cada aplicación web moderna.

Entendiendo la política del mismo origen (SOP)

Antes de profundizar en la configuración de CORS, es esencial comprender su base: la política del mismo origen (SOP).

Como se mencionó anteriormente, la SOP es la primera línea de defensa del navegador contra comportamientos maliciosos en la web. Impide que un sitio web acceda libremente a los datos de otro, lo que podría exponer información sensible como cookies, tokens de autenticación o detalles personales.

Así es como funciona en la práctica: cuando una página web se carga en tu navegador, se le asigna un origen basado en tres elementos: el protocolo, el host y el puerto:

https://   api.example.com   :443
^          ^                 ^
protocolo  host              puerto

Dos URLs se consideran del mismo origen solo si las tres partes coinciden. De lo contrario, el navegador las trata como de origen cruzado.

Esta simple regla detiene las acciones maliciosas entre sitios. Sin ella, un sitio aleatorio podría cargar tu panel de banca online en un frame invisible, leer tu saldo y enviarlo a un atacante, todo sin tu consentimiento.

En resumen, la SOP existe para aislar el contenido entre diferentes sitios, asegurando que cada origen sea una zona de seguridad autocontenida.

Por qué la SOP por sí sola no fue suficiente

La política del mismo origen funcionaba perfectamente cuando los sitios web eran autocontenidos. Pero a medida que la web evolucionó hacia un ecosistema de APIs, microservicios y arquitecturas distribuidas, esta estricta regla se convirtió en una limitación importante.

Las aplicaciones modernas necesitaban:

  • Llamar a sus propias APIs alojadas en diferentes subdominios (app.example.com → api.example.com)
  • Obtener activos de CDNs o servicios de terceros
  • Integrarse con APIs externas como Stripe, Firebase o Google Maps

Bajo la SOP, estas solicitudes legítimas de origen cruzado eran bloqueadas. Los desarrolladores intentaron todas las soluciones posibles, incluyendo JSONP, proxies inversos o dominios duplicados, pero estas soluciones eran inseguras o dolorosamente complejas.

Ahí es donde CORS (Cross-Origin Resource Sharing) cambió las reglas del juego.

CORS introdujo un sistema de handshake que permitía a navegadores y servidores negociar la confianza. En lugar de romper la SOP, la extendió, proporcionando una forma de incluir de forma segura en una lista blanca (whitelist) orígenes específicos para la comunicación entre dominios.

Cómo funciona CORS: El flujo a nivel de protocolo

Como se mencionó anteriormente, cuando su navegador realiza una solicitud a un origen diferente, no la envía a ciegas. En su lugar, sigue un protocolo CORS bien definido: una conversación bidireccional entre el navegador y el servidor para determinar si la solicitud debe ser permitida.

En esencia, CORS funciona a través de cabeceras HTTP. El navegador adjunta una cabecera Origin a cada solicitud de origen cruzado, indicando al servidor de dónde proviene la solicitud. El servidor responde entonces con una o más cabeceras Access-Control-* que definen lo que está permitido.

Aquí tiene un ejemplo simplificado de esa conversación:

# Request
GET /api/data HTTP/1.1
Host: api.example.com
Origin: https://app.example.com

# Response
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Content-Type: application/json

{"message": "Success"}
Flujo CORS de Solicitud y Respuesta
Flujo CORS de Solicitud y Respuesta

En este caso, el servidor permite explícitamente que el origen https://app.example.com acceda a su recurso. El navegador verifica esta respuesta, confirma la coincidencia y entrega los datos a su JavaScript.

Pero si los orígenes no coinciden o si las cabeceras de respuesta faltan o son incorrectas, el navegador bloquea silenciosamente la respuesta. No verá los datos, solo ese frustrante mensaje de “error CORS” en su consola.

Es importante destacar que CORS no hace que un servidor sea más seguro por sí mismo. En cambio, impone reglas de interacción entre navegadores y servidores, una capa de seguridad que garantiza que solo los orígenes de confianza puedan acceder a recursos protegidos.

Tipos de solicitudes CORS

CORS define dos tipos principales de solicitudes: simples y preflight. La diferencia radica en la cantidad de verificación que realiza el navegador antes de enviar datos.

1. Solicitudes simples

Una solicitud simple es el tipo más directo. Los navegadores la permiten automáticamente siempre que siga reglas específicas:

  • Utiliza uno de estos métodos: GET, HEAD o POST

  • Incluye solo ciertas cabeceras:

    • Accept
    • Accept-Language
    • Content-Language
    • Content-Type (pero solo application/x-www-form-urlencoded, multipart/form-data o text/plain)
  • No utiliza cabeceras o streams personalizados

Así es como se ve:

# Request
GET /api/data HTTP/1.1
Host: api.example.com
Origin: https://app.example.com

# Response
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Content-Type: application/json

{"message": "This is the response data"}

En este caso:

  • El navegador añade automáticamente la cabecera Origin.
  • El servidor debe devolver Access-Control-Allow-Origin con un origen coincidente.
  • Si el origen no coincide o falta, el navegador bloquea la respuesta.

2. Solicitudes preflight

La situación se vuelve más interesante con las solicitudes no simples. Por ejemplo, al utilizar métodos como PUT, DELETE o cabeceras personalizadas como Authorization.

Antes de enviar la solicitud real, el navegador realiza una comprobación preflight utilizando una solicitud OPTIONS. Este paso asegura que el servidor permite explícitamente la operación prevista.

Aquí tienes un ejemplo:

# Preflight Request
OPTIONS /api/data HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: content-type, authorization

# Preflight Response
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: PUT, POST, GET, DELETE
Access-Control-Allow-Headers: content-type, authorization
Access-Control-Max-Age: 3600

# Actual Request (only sent if preflight succeeds)
PUT /api/data HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Content-Type: application/json
Authorization: Bearer token123

{"data": "update this resource"}

En esta secuencia:

  1. El navegador detecta una solicitud no simple.
  2. Envía una solicitud OPTIONS preflight, solicitando permiso para el método y las cabeceras reales.
  3. El servidor responde con los métodos, cabeceras y orígenes que permite.
  4. Si la comprobación preflight es exitosa, el navegador envía la solicitud real. De lo contrario, la bloquea.

Gestión de Credenciales en CORS

Al tratar con APIs que requieren autenticación, como cookies, tokens o inicios de sesión basados en sesión, CORS se comporta de manera diferente.

Por defecto, los navegadores tratan las solicitudes de origen cruzado como no autenticadas por razones de seguridad. Esto significa que las cookies o las cabeceras de autenticación HTTP no se incluyen automáticamente.

Para habilitar solicitudes con credenciales de forma segura, dos pasos clave deben coincidir:

1. El cliente debe permitir explícitamente las credenciales:

fetch('https://api.example.com/data', {
  credentials: 'include'
})

2. El servidor debe permitirlas explícitamente:

Access-Control-Allow-Credentials: true

Pero hay un inconveniente, y es considerable.

Cuando Access-Control-Allow-Credentials se establece a true, no puedes usar un comodín (*) en Access-Control-Allow-Origin. Los navegadores rechazarán la respuesta si lo intentas.

Esto se debe a que permitir que todos los orígenes envíen solicitudes con credenciales frustraría el propósito completo de la seguridad CORS, ya que permitiría que cualquier sitio en internet accediera a datos privados vinculados a la sesión de un usuario.

Así que, en lugar de esto:

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

Siempre debes usar un origen específico:

Access-Control-Allow-Origin: https://yourapp.com
Access-Control-Allow-Credentials: true

Si tu API sirve a múltiples dominios de confianza, puedes devolver dinámicamente la cabecera de origen correcta en el lado del servidor:

const allowedOrigins = ['https://app1.com', 'https://app2.com'];
const origin = req.headers.origin;

if (allowedOrigins.includes(origin)) {
  res.setHeader('Access-Control-Allow-Origin', origin);
  res.setHeader('Access-Control-Allow-Credentials', 'true');
}

Este enfoque asegura que tus solicitudes autenticadas permanezcan seguras e intencionales, no abiertas a cualquiera que lo intente.

Cómo los Navegadores Determinan la Elegibilidad de las Solicitudes para CORS

Antes de que una solicitud llegue a tu servidor, el navegador decide si se rige por las reglas CORS.

Esta decisión depende del origen de la solicitud y de si se dirige a otro dominio, puerto o protocolo.

Por ejemplo:

  • Solicitando https://api.example.com desde una página servida en https://example.com: ✅ CORS aplica (subdominio diferente).
  • Solicitando https://example.com:3000 desde https://example.com:  ✅ CORS aplica (puerto diferente).
  • Solicitando https://example.com desde el mismo dominio y puerto: ❌ CORS no aplica.

Si el navegador detecta que una solicitud cruza orígenes, incluye automáticamente la cabecera Origin en la solicitud:

Origin: https://example.com

Esta cabecera indica al servidor de dónde procede la solicitud y es lo que el servidor utiliza para decidir si permite o bloquea el acceso.

Si la respuesta carece de las cabeceras correctas (como Access-Control-Allow-Origin), el navegador simplemente bloquea el acceso a la respuesta, aunque el servidor técnicamente haya enviado una.

Esa es una distinción importante: el navegador es quien aplica CORS, no el servidor.

Comprobaciones de seguridad internas, XMLHttpRequest vs Fetch y diferencias entre navegadores

No todos los navegadores gestionan CORS de la misma manera, pero todos siguen el mismo modelo de seguridad: nunca confiar en datos de origen cruzado a menos que se permita explícitamente.

Lo que difiere es la rigurosidad con la que aplican las reglas y a qué APIs las aplican.

1. La comprobación de seguridad interna de CORS

Cuando un navegador recibe una respuesta a una solicitud de origen cruzado, realiza un paso de validación interno antes de exponer la respuesta a tu código JavaScript.

Comprueba cabeceras como:

  • Access-Control-Allow-Origin: debe coincidir con el origen de la solicitud (o ser * en algunos casos).
  • Access-Control-Allow-Credentials: debe ser true si hay cookies o tokens de autenticación implicados.
  • Access-Control-Allow-Methods y Access-Control-Allow-Headers: deben coincidir con la solicitud preflight original si se envió una.

Si alguna de estas comprobaciones falla, el navegador no genera un error HTTP, simplemente bloquea el acceso a la respuesta y registra un error CORS en la consola.

Esto dificulta la depuración, porque la solicitud de red real aun así tuvo éxito, pero el navegador oculta el resultado por seguridad.

2. XMLHttpRequest vs Fetch

Tanto XMLHttpRequest como la API moderna fetch() son compatibles con CORS, pero se comportan de forma ligeramente diferente en lo que respecta a las credenciales y los valores predeterminados.

Con XMLHttpRequest:

  • Las cookies y la autenticación HTTP se envían automáticamente si withCredentials se establece en true.
  • El comportamiento de preflight depende de si se añaden cabeceras personalizadas.

Con Fetch:

  • Las credenciales (cookies, autenticación HTTP) no se incluyen por defecto.
  • Debes habilitarlas explícitamente usando:
fetch("https://api.example.com/data", {
	credentials: "include"
});
  • fetch también trata las redirecciones de forma más estricta bajo CORS, ya que no seguirá las redirecciones entre orígenes a menos que se le permita.

Así, aunque fetch es más limpio y moderno, también es menos indulgente cuando olvidas una cabecera o pasas por alto una regla de credenciales.

3. Diferencias y peculiaridades de los navegadores

Aunque la especificación CORS es estándar, los navegadores la implementan con diferencias sutiles:

  • Safari puede ser excesivamente estricto con las cookies y las solicitudes con credenciales, especialmente cuando las cookies de terceros están bloqueadas.
  • Firefox a veces almacena en caché las respuestas preflight fallidas más tiempo de lo esperado, lo que lleva a resultados inconsistentes durante las pruebas.
  • Chrome aplica CORS en ciertas cadenas de redirección de forma más agresiva que otros.

Debido a estas diferencias, una configuración que funciona perfectamente en un navegador puede fallar silenciosamente en otro.

Por eso es fundamental probar las configuraciones CORS en diferentes navegadores, especialmente cuando hay credenciales o redirecciones involucradas.

Gestión del encabezado Origin en el lado del servidor

Mientras el navegador aplica CORS, la verdadera toma de decisiones ocurre en el servidor.

Cuando el navegador envía una solicitud de origen cruzado, siempre incluye la cabecera Origin. La tarea del servidor es inspeccionar esa cabecera, decidir si la permite y devolver las cabeceras CORS correctas en la respuesta.

1. Validación del origen

Una solicitud típica podría llegar así: Origin: https://frontend.example.com

En el servidor, tu código necesita verificar si este origen está permitido. El enfoque más simple (y seguro) es mantener una lista blanca de dominios de confianza:

const allowedOrigins = ["https://frontend.example.com", "https://admin.example.com"];
if (allowedOrigins.includes(req.headers.origin)) {
  res.setHeader("Access-Control-Allow-Origin", req.headers.origin);
}

Esto asegura que solo los clientes conocidos puedan acceder a tu API, mientras que otros no reciben permiso CORS.

Evita devolver Access-Control-Allow-Origin: * si tu API maneja cookies, tokens u otras credenciales.

2. Gestión de solicitudes Preflight

Para las solicitudes preflight OPTIONS, el servidor debe responder con el mismo cuidado que para las solicitudes principales.
Una respuesta preflight completa incluye:

Access-Control-Allow-Origin: https://frontend.example.com
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400

Estas cabeceras indican al navegador qué está permitido y cuánto tiempo puede almacenar en caché esa decisión. Si alguna de ellas falta o es incorrecta, el navegador bloqueará la solicitud subsiguiente, incluso si el propio endpoint funciona correctamente.

3. Configuración dinámica de cabeceras CORS

En sistemas grandes (como plataformas multi-tenant o APIs con múltiples clientes), los orígenes permitidos pueden necesitar ser dinámicos.

Por ejemplo:

const origin = req.headers.origin;

if (origin && origin.endsWith(".trustedclient.com")) {
  res.setHeader("Access-Control-Allow-Origin", origin);
}

Este patrón permite todos los subdominios de un dominio de confianza, al mismo tiempo que filtra las fuentes desconocidas.

Solo asegúrate de validar los orígenes cuidadosamente y no hagas coincidir cadenas con la entrada del usuario sin restricciones, o los atacantes podrían falsificar cabeceras que parezcan válidas.

4. Por qué «Funciona en Postman» no significa que esté configurado correctamente

Una de las mayores ideas erróneas sobre CORS es esta: «Funciona en Postman, así que debe ser un problema del navegador».

Postman no aplica CORS en absoluto, porque no es un navegador.

Esto significa que incluso una API completamente abierta sin cabeceras Access-Control-* responderá correctamente allí, pero fallará inmediatamente en Chrome o Firefox.

Así que si tu API funciona en Postman pero no en tu aplicación web, es probable que tus cabeceras CORS estén incompletas o mal configuradas.

Errores comunes de configuración de CORS (y cómo evitarlos)

1. Uso de Access-Control-Allow-Origin: * con credenciales

Este es el error más frecuente y peligroso.
Si tu respuesta incluye ambos:

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

...el navegador bloqueará la solicitud automáticamente.

La especificación CORS prohíbe el uso de comodines cuando se incluyen credenciales, porque permitiría a cualquier sitio acceder a datos de usuario vinculados a cookies o tokens de autenticación.

Solución: Siempre devuelve un origen específico cuando se utilizan credenciales:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true

2. Olvidar gestionar las solicitudes preflight

Muchas APIs responden correctamente a GET y POST, pero olvidan la solicitud preflight OPTIONS.

Cuando esto ocurre, el navegador nunca llega a tu endpoint real y bloquea la solicitud principal después del preflight fallido.

Solución: Gestiona explícitamente las solicitudes OPTIONS y responde con las cabeceras correctas:

if (req.method === "OPTIONS") {
  res.setHeader("Access-Control-Allow-Origin", req.headers.origin);
  res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
  res.setHeader("Access-Control-Allow-Headers", "Content-Type,Authorization");
  return res.sendStatus(204);
}

3. Cabeceras de solicitud y respuesta no coincidentes

Otro problema sutil: tu solicitud preflight podría pedir ciertas cabeceras, pero el servidor no las permite explícitamente.

Por ejemplo, si tu solicitud incluye:

Access-Control-Request-Headers: Authorization, Content-Type

...pero el servidor solo responde con:

Access-Control-Allow-Headers: Content-Type

...el navegador lo bloquea. Ambas listas deben coincidir exactamente.

Solución: Asegúrate de que tu Access-Control-Allow-Headers incluya todos los encabezados que el cliente pueda enviar, especialmente Authorization, Accept y los encabezados personalizados.

4. Devolución de múltiples encabezados Access-Control-Allow-Origin

Algunos proxies o frameworks mal configurados envían múltiples encabezados Access-Control-Allow-Origin (por ejemplo, uno estático * y otro de origen dinámico).

Los navegadores lo consideran inválido y bloquean la solicitud por completo.

Solución: Devuelve siempre un único encabezado Access-Control-Allow-Origin válido.

5. Olvidar las restricciones de método

Si no incluyes todos los métodos permitidos en Access-Control-Allow-Methods, los navegadores rechazarán las solicitudes legítimas.

Por ejemplo, una API podría admitir PUT, pero tu respuesta preflight solo permite GET y POST.

Solución: Enumera cada método compatible o empareja dinámicamente tus rutas de API para asegurar la coherencia.

6. Ignorar las respuestas preflight cacheadas

Los navegadores modernos cachean los resultados preflight para mejorar el rendimiento.
Pero si tu servidor o CDN cachea las respuestas sin variar por Origin, podrías enviar accidentalmente los encabezados CORS incorrectos a otro cliente.

Solución: Utiliza el encabezado Vary: Origin para asegurar que las respuestas se cacheen por separado para cada origen.

Los problemas de CORS rara vez provienen de un gran error. Suelen ser el resultado de varias pequeñas discrepancias entre las expectativas del navegador y la configuración del servidor. Comprender estos patrones te ayuda a evitar ciclos interminables de depuración de "errores CORS".

CORS no es el enemigo, lo es su incomprensión

A primera vista, CORS parece una barrera innecesaria, o más bien un guardián que rompe tus solicitudes y ralentiza el desarrollo.

Pero en realidad, es una de las características de seguridad del navegador más importantes jamás creadas.

Una vez que entiendes cómo funciona, dejas de ver los "errores CORS" como fallos aleatorios, y en su lugar, se convierten en señales de que tu cliente y servidor necesitan alinearse mejor en cuanto a confianza, encabezados o credenciales.

Ya sea que estés construyendo una aplicación de una sola página o un ecosistema de API distribuido, CORS es tu aliado para mantener a los usuarios seguros mientras permite una comunicación segura entre dominios.

Así que la próxima vez que te encuentres con ese mensaje familiar en la consola, no recurras al comodín. Lee los encabezados, rastrea la lógica y deja que tu comprensión, y no un arreglo aleatorio, guíe la solución.

Compartir:

https://www.aikido.dev/blog/cors-security-beyond-basic-configuration

Suscríbase para recibir noticias sobre amenazas.

Empieza hoy mismo, gratis.

Empieza gratis
Sin tarjeta

Asegura tu plataforma ahora

Protege tu código, la nube y el entorno de ejecución en un único sistema central.
Encuentra y corrije vulnerabilidades de forma rápida y automática.

No se requiere tarjeta de crédito | Resultados del escaneo en 32 segundos.