CORS empieza con la same-origin policy
La same-origin policy es un mecanismo de seguridad fundamental del navegador. Limita la capacidad de un documento o script para leer datos de otro origin y ayuda a impedir que un sitio malicioso lea información de un servicio donde el usuario ya está autenticado. [3]
CORS, Cross-Origin Resource Sharing, es un mecanismo HTTP que permite al servidor relajar esa regla y declarar explícitamente qué origins pueden leer una respuesta. Fetch Standard lo describe como un protocolo opt-in. [1][2]
Qué es exactamente un origin
| Página | URL objetivo | Relación | Por qué |
|---|---|---|---|
| https://app.example.com | https://app.example.com/api | Mismo origin | Mismo esquema, host y puerto |
| https://app.example.com | https://api.example.com | Origin distinto | Host distinto |
| https://app.example.com | http://app.example.com | Origin distinto | Esquema distinto |
| https://app.example.com | https://app.example.com:8443 | Origin distinto | Puerto distinto |
| https://app.example.com | https://app.example.com/v2 | Mismo origin | Solo cambia la ruta |
Un origin se define por esquema, host y puerto. Dos URL son same-origin solo si coinciden los tres. La ruta no importa. [3][4]
Por tanto, `https://app.example.com` y `https://api.example.com` son origins distintos. También lo son HTTP frente a HTTPS o dos puertos distintos. [4]
Same-origin y same-site no son conceptos equivalentes. Es especialmente importante con cookies, porque `SameSite` usa el concepto de site, mientras CORS usa origin. [13]
Qué significa realmente que el navegador bloquea la API
Decir que el navegador bloquea la petición suele ser demasiado simple. Para una petición cross-origin simple, puede enviarla, recibir una respuesta HTTP válida y después negarse a exponerla a JavaScript si falla la comprobación CORS. [1][6]
Para peticiones con preflight, el flujo cambia. El navegador envía primero `OPTIONS`. Si falla el preflight, la petición real no se envía. [2][5][6]
Esta diferencia importa en operaciones que modifican datos. Un error CORS no implica automáticamente que el backend no haya recibido ninguna petición.
Peticiones simples: cuándo se evita el preflight
| Método | Headers de ejemplo | Petición simple | Efecto |
|---|---|---|---|
| GET | Accept | Sí | Sin preflight |
| POST | Content-Type: text/plain | Sí | Sin preflight |
| POST | Content-Type: application/json | No | JSON provoca preflight |
| PUT | Content-Type: application/json | No | El método provoca preflight |
| GET | Authorization: Bearer ... | No | El header provoca preflight |
Una petición solo es simple si cumple las condiciones de la CORS safelist. Los métodos permitidos son `GET`, `HEAD` y `POST`. Los headers establecidos manualmente deben estar en la lista permitida y `Content-Type` solo puede usar `application/x-www-form-urlencoded`, `multipart/form-data` o `text/plain`. [1]
Un `POST` típico con `Content-Type: application/json` no es simple y suele provocar preflight. Lo mismo ocurre con `PUT`, `DELETE` o headers personalizados como `X-Request-ID`. [1][11]
Preflight OPTIONS: qué comprueba el navegador
El preflight es una petición `OPTIONS` automática. El navegador envía `Origin`, `Access-Control-Request-Method` y, si hace falta, `Access-Control-Request-Headers`. El servidor responde con origins, métodos y headers permitidos. [2][5]
Si la aplicación quiere enviar `DELETE` con `Authorization`, el navegador puede comprobar primero si ese origin puede usar ese método y ese header. Solo una respuesta positiva permite continuar. [5]
El preflight añade un round trip adicional, aunque el resultado puede guardarse en una caché CORS específica separada de la caché HTTP normal. [5][10]
Los headers CORS más importantes
| Header | Dirección | Función |
|---|---|---|
| Origin | Petición | Origin que inicia la petición |
| Access-Control-Allow-Origin | Respuesta | Origin cuyo código puede recibir la respuesta |
| Access-Control-Allow-Methods | Respuesta preflight | Métodos permitidos por preflight |
| Access-Control-Allow-Headers | Respuesta preflight | Headers de petición permitidos por preflight |
| Access-Control-Allow-Credentials | Respuesta | Permiso para exponer respuesta con credentials |
| Access-Control-Expose-Headers | Respuesta | Headers adicionales de respuesta visibles para JavaScript |
| Access-Control-Max-Age | Respuesta preflight | Tiempo de caché del resultado preflight |
| Vary: Origin | Respuesta | Indica a la caché que la respuesta varía según Origin |
`Origin` identifica el origin que inicia la petición. `Access-Control-Allow-Origin` es un header de respuesta que indica si el navegador puede compartir la respuesta con código de ese origin. [1][2][8]
`Access-Control-Allow-Methods` y `Access-Control-Allow-Headers` son especialmente relevantes para preflight. `Access-Control-Allow-Credentials` permite exponer respuestas con credentials y `Access-Control-Expose-Headers` puede mostrar headers adicionales a JavaScript. [1][2]
Si el servidor devuelve dinámicamente `Access-Control-Allow-Origin`, MDN recomienda también `Vary: Origin`. [1]
`Access-Control-Allow-Origin`: wildcard u origin explícito
`Access-Control-Allow-Origin: *` permite compartir una respuesta con cualquier origin para peticiones sin credentials. Puede ser correcto para una API realmente pública. [2][8]
Para endpoints privados o sensibles, el conjunto permitido debe ser mínimo. MDN y OWASP recomiendan origins específicos cuando no se necesita acceso global. [14][17]
Si se permiten varios origins, el servidor debe comparar el `Origin` entrante con una allowlist y devolver solo el valor aceptado. No debe reflejar cualquier origin sin validación. [8][16]
Cookies y credentials: fuente frecuente de errores
Fetch puede usar `credentials: "include"` para incluir credentials en peticiones cross-origin. Eso no basta. El servidor debe responder con `Access-Control-Allow-Credentials: true` y un `Access-Control-Allow-Origin` explícito. `*` no está permitido en este caso. [2][6][9]
Las cookies también siguen sus propias reglas. `SameSite` puede impedir que una cookie se envíe en una petición cross-site y `SameSite=None` requiere `Secure`. [13]
CORS y `SameSite` resuelven problemas diferentes. CORS puede ser correcto y faltar la cookie, o la cookie puede enviarse y CORS seguir bloqueando el acceso de JavaScript a la respuesta.
Por qué `mode: "no-cors"` normalmente no arregla nada
`fetch(..., { mode: "no-cors" })` no es una forma de saltarse CORS cuando la aplicación necesita leer la respuesta. Métodos y headers quedan restringidos y la respuesta es opaque. JavaScript no puede leer body ni headers y el status expuesto es `0`. [6][7][11]
`no-cors` tiene usos especializados, incluidos algunos escenarios con Service Worker, pero suele ser una mala solución para una API JSON normal. [6]
Si no controlas la API externa y no ofrece CORS, un backend o proxy propio puede hacer la petición servidor a servidor. [11][12]
Por qué curl, Postman o un backend pueden funcionar
La same-origin policy y CORS son mecanismos aplicados por el navegador. Un cliente HTTP fuera de ese modelo no está sujeto a la misma comprobación. Por eso un endpoint puede funcionar con curl o una herramienta API y fallar desde JavaScript de una página. [2][3][16]
Esto demuestra también que CORS no es autenticación de API. Un cliente fuera del navegador puede elegir su propio `Origin`, por lo que OWASP desaconseja usarlo como prueba de identidad. [16]
La API sigue necesitando autorización, tokens, sesiones, controles de permisos y validación en servidor.
CORS no sustituye CSRF ni autorización
La same-origin policy limita principalmente lecturas cross-origin. Formularios y algunas peticiones simples pueden enviarse entre sitios, por lo que las aplicaciones basadas en cookies siguen necesitando defensa CSRF. [3][15]
OWASP recomienda expresamente no usar solo CORS u `Origin` como control de acceso a recursos sensibles. Autenticación y autorización siguen siendo necesarias. [16]
CORS responde si el navegador puede exponer una respuesta a código de un origin, no si el usuario o cliente tiene permiso para ejecutar la operación.
Errores frecuentes y diagnóstico
La ausencia de `Access-Control-Allow-Origin` es un error habitual. También son frecuentes un método no permitido, headers ausentes en `Access-Control-Allow-Headers`, `*` con credentials, una mala respuesta OPTIONS o redirects dentro del flujo CORS. [11][12]
JavaScript recibe deliberadamente pocos detalles. La consola y Network de DevTools ofrecen el diagnóstico más útil. [1][11]
`CORS request did not succeed` también puede deberse a DNS, timeout, conexión rechazada, TLS, mixed content o extensiones del navegador. [11]
- Comprueba el origin exacto del frontend: esquema, host y puerto.
- Inspecciona `OPTIONS` cuando exista.
- Revisa status y headers `Access-Control-Allow-*` del preflight.
- Comprueba que la respuesta real también incluya los headers CORS necesarios.
- Con cookies revisa `credentials`, `Access-Control-Allow-Credentials`, `SameSite` y `Secure`.
- Revisa redirects, TLS, mixed content y errores de red.
- Compara la petición del navegador con una petición funcional de curl o una herramienta API.
Configuraciones CORS peligrosas
Reflejar ciegamente el `Origin` recibido es peligroso cuando peticiones con credentials devuelven datos sensibles. Un atacante puede controlar un origin e intentar leer respuestas en el contexto de un usuario autenticado. [16]
Expresiones regulares demasiado amplias y confiar en todos los subdominios también puede ser arriesgado si alguno puede ser tomado o controlado por otra parte. OWASP recomienda una allowlist con coincidencia exacta. [16][17]
`Access-Control-Allow-Origin: *` no es automáticamente una vulnerabilidad para datos totalmente públicos sin credentials. El problema es una política más permisiva de lo necesario. [8][17]
Rendimiento: coste del preflight y `Access-Control-Max-Age`
El preflight añade una petición `OPTIONS` antes de la real y puede aumentar latencia. Su resultado puede cachearse con `Access-Control-Max-Age` en una caché separada de la HTTP normal. [5][10]
MDN indica que el valor por defecto sin header es 5 segundos. Los navegadores aplican límites propios. Firefox limita a 86400 segundos y Chromium desde la versión 76 a 7200 segundos. [10]
Conviene cachear políticas estables, pero no tanto como para dificultar retirar rápidamente una política accidentalmente demasiado amplia.
Configuración práctica y checklist de despliegue
Primero decide si el endpoint debe ser accesible cross-origin. Si no, no añadas headers CORS. Si sí, define allowlist de origins, métodos, headers y si se necesitan credentials. [14][17]
Con una allowlist dinámica, devuelve solo un origin validado y añade `Vary: Origin`. Con credentials, devuelve origin explícito y `Access-Control-Allow-Credentials: true`. El frontend no puede sustituir esta decisión del servidor. [1][2]
Después del despliegue prueba una petición simple, un preflight, un escenario con cookie, un origin no permitido y errores reales en DevTools.
- ¿El acceso cross-origin es realmente necesario?
- ¿Qué origins exactos deben permitirse?
- ¿Se necesitan credentials?
- ¿Qué métodos y headers de petición son necesarios?
- ¿`OPTIONS` llega a la aplicación y responde correctamente?
- ¿La respuesta real contiene los headers CORS necesarios?
- ¿Las respuestas con origin dinámico incluyen `Vary: Origin`?
- ¿El endpoint sigue exigiendo autenticación y autorización normales?
- ¿Se ha probado la política con un origin permitido y otro no permitido?

