CORS y SameSite:
el modelo de origen del navegador explicado sin mitos
Seguridad de aplicaciones web | unfantasmaenelsistema.com
Pocas cosas generan tantos malentendidos entre quien programa para la web como CORS. Se le echa la culpa de errores que no ha causado, se «arregla» abriendo agujeros de seguridad reales, y casi nadie sabría decir en una frase qué protege exactamente. La confusión viene de mezclar tres cosas distintas: la Same-Origin Policy (la regla base del navegador), CORS (una forma controlada de relajarla) y el atributo SameSite de las cookies (una defensa contra un ataque completamente diferente).
En este artículo separamos las tres piezas, explicamos qué hace cada una, qué NO hace, y cuáles son los errores de configuración que aparecen una y otra vez en auditorías. El objetivo es que, la próxima vez que veas un error de CORS en la consola, sepas si el problema está en tu servidor, en tu cliente, o en que estás pidiendo algo que el navegador no debe permitir.
Qué aprenderás en este artículo
Qué es un «origen» y qué prohíbe exactamente la Same-Origin Policy · Qué relaja CORS y por qué NO es un control de acceso · Cómo funciona la petición preflight (OPTIONS) y qué cabeceras la gobiernan · Por qué Access-Control-Allow-Origin: * junto con credenciales está prohibido · Cómo cortan el CSRF las cookies SameSite=Lax/Strict/None y dónde encaja la CSP
1. El origen y la Same-Origin Policy
Un origen es la combinación de tres elementos de una URL: el esquema (http o https), el host (el nombre de dominio) y el puerto. Si cualquiera de los tres difiere, hablamos de orígenes distintos. Ojo: el subdominio cuenta como host diferente, y http y https son orígenes diferentes aunque el dominio sea el mismo.
| URL comparada con https://app.ejemplo.com | ¿Mismo origen? | Motivo |
|---|---|---|
| https://app.ejemplo.com/perfil | Sí | Solo cambia la ruta, que no forma parte del origen |
| http://app.ejemplo.com | No | Esquema distinto (http vs https) |
| https://api.ejemplo.com | No | Host distinto (subdominio diferente) |
| https://app.ejemplo.com:8443 | No | Puerto distinto |
| https://ejemplo.com | No | Host distinto (dominio raíz vs subdominio) |
La Same-Origin Policy (SOP) es la regla de seguridad más antigua y fundamental del navegador: el código JavaScript de un origen puede enviar peticiones a otro origen, pero no puede leer la respuesta salvo que ese otro origen lo autorice explícitamente. También aísla el acceso al DOM de ventanas de otro origen, a las cookies y al almacenamiento local. Sin la SOP, cualquier pestaña abierta con una web maliciosa podría leer tu correo web, tu banca o tu panel de administración aprovechando que tu navegador ya está autenticado en esos sitios.
Lo que la Same-Origin Policy NO impide
No impide que se envíe la petición: la carga de imágenes, hojas de estilo, scripts y el envío de formularios entre orígenes siempre ha estado permitido · No impide que el servidor de destino reciba y procese esa petición con las cookies del usuario adjuntas — de ahí que exista el CSRF · Bloquea la LECTURA de la respuesta por parte del script, no el efecto secundario que la petición haya provocado en el servidor
2. CORS: un permiso de lectura, no un muro
CORS (Cross-Origin Resource Sharing) es el mecanismo por el que un servidor le dice al navegador: «permito que el JavaScript de este otro origen lea mis respuestas». Es importante entender la dirección: CORS no protege tu API de nadie. Una petición hecha con curl, con Postman o desde otro servidor ignora CORS por completo, porque CORS es una regla que aplica el navegador, no el servidor. Su único efecto es decidir si el script que hizo la llamada podrá ver el resultado o recibirá un error.
Dicho de otra forma: si tu endpoint devuelve datos sensibles sin comprobar la sesión del usuario, poner CORS restrictivo no lo arregla; y si comprueba la sesión correctamente, un CORS permisivo no necesariamente lo rompe. CORS y autorización son capas separadas.
2.1 Peticiones simples y peticiones con preflight
El navegador clasifica las peticiones entre orígenes en dos grupos. Una petición se considera simple si usa GET, HEAD o POST, y solo lleva cabeceras de una lista corta permitida (y, en el caso de POST, un Content-Type de tipo formulario o texto plano). Esas se envían directamente y después el navegador comprueba la respuesta.
Cualquier otra cosa —un método PUT o DELETE, una cabecera Authorization o X-CSRF-Token, un Content-Type: application/json— dispara una petición preflight: antes de la petición real, el navegador envía automáticamente un OPTIONS preguntando si la operación está permitida. Solo si la respuesta al preflight lo autoriza, se envía la petición de verdad.
# Preflight que envia el navegador (automatico, no lo escribes tu) OPTIONS /api/pedidos HTTP/1.1 Host: api.ejemplo.com Origin: https://app.ejemplo.com Access-Control-Request-Method: DELETE Access-Control-Request-Headers: authorization, content-type # Respuesta del servidor que AUTORIZA esa operacion HTTP/1.1 204 No Content Access-Control-Allow-Origin: https://app.ejemplo.com Access-Control-Allow-Methods: GET, POST, DELETE Access-Control-Allow-Headers: authorization, content-type Access-Control-Allow-Credentials: true Access-Control-Max-Age: 600
2.2 Las cabeceras de respuesta que gobiernan CORS
| Cabecera | Para qué sirve |
|---|---|
| Access-Control-Allow-Origin | El origen (uno solo) o * al que se permite leer la respuesta |
| Access-Control-Allow-Methods | Métodos HTTP autorizados para peticiones con preflight |
| Access-Control-Allow-Headers | Cabeceras de petición no estándar que el cliente puede enviar |
| Access-Control-Allow-Credentials | Si vale true, permite enviar cookies o credenciales en la petición |
| Access-Control-Expose-Headers | Cabeceras de respuesta que el script podrá leer además de las básicas |
| Access-Control-Max-Age | Segundos que el navegador puede cachear el resultado del preflight |
3. Los errores de configuración que aparecen en toda auditoría
3.1 Reflejar el Origin sin validarlo
El patrón más peligroso: el servidor lee la cabecera Origin de la petición y la copia tal cual en Access-Control-Allow-Origin, normalmente junto con Access-Control-Allow-Credentials: true. El resultado es que cualquier web del mundo puede hacer peticiones autenticadas a tu API y leer la respuesta con las cookies de sesión de tu usuario. Es equivalente a no tener SOP para ese endpoint. Suele colarse porque «así funcionaba en desarrollo» y nadie lo revisó antes de producción.
3.2 Comodín * combinado con credenciales
La especificación prohíbe expresamente que Access-Control-Allow-Origin: * coexista con Access-Control-Allow-Credentials: true. Si intentas enviar cookies con una petición cuyo servidor responde con el comodín, el navegador bloquea la lectura. La única forma válida de permitir credenciales entre orígenes es responder con el origen concreto (previa comprobación contra una lista blanca) y añadir la cabecera Vary: Origin para que las cachés no mezclen respuestas.
3.3 Validación de origen con coincidencia parcial
- Comprobar solo que el Origin «empieza por» https://app.ejemplo.com — deja pasar https://app.ejemplo.com.atacante.com
- Comprobar solo que «contiene» ejemplo.com — deja pasar https://ejemplo.com.atacante.com y https://atacante-ejemplo.com
- Añadir null a la lista blanca — algunos contextos (iframes con sandbox, redirecciones) envían Origin: null, y compartirlo abre la puerta a esos contextos
Regla práctica para CORS con credenciales
Mantén una lista blanca explícita de orígenes permitidos y compárala por igualdad exacta, nunca con «contiene» o «empieza por» · Si el origen no está en la lista, no emitas ninguna cabecera Access-Control-Allow-Origin (no respondas con un valor por defecto) · Reserva Access-Control-Allow-Credentials: true solo para los endpoints que de verdad lo necesitan, y documenta por qué · Añade siempre Vary: Origin cuando el valor de la cabecera dependa de la petición
4. Cookies SameSite: la otra mitad de la historia
CORS regula quién puede leer respuestas entre orígenes. No hace nada contra el CSRF (Cross-Site Request Forgery), que es un ataque en el que el navegador de la víctima envía una petición no deseada a un sitio donde ya está autenticada, y el atacante ni necesita leer la respuesta: le basta con el efecto (transferir dinero, cambiar el correo de recuperación, borrar algo). La defensa moderna de primera línea contra el CSRF es el atributo SameSite de la cookie de sesión.
| Valor de SameSite | Cuándo se envía la cookie | Efecto sobre el CSRF |
|---|---|---|
| Strict | Solo en peticiones originadas desde el propio sitio | Máxima protección; rompe la navegación entrante desde enlaces externos (llegas sin sesión) |
| Lax | Desde el propio sitio, y en navegaciones de nivel superior con GET desde fuera | Buen equilibrio; bloquea POST, PUT y DELETE entre sitios. Es el valor por defecto en los navegadores actuales |
| None | Siempre, también en contextos de terceros | Ninguna protección por sí sola; obligatorio marcarla también como Secure. Necesaria para SSO y widgets embebidos |
Que Lax sea hoy el valor por defecto implícito ha reducido mucho el CSRF clásico basado en formularios POST automáticos. Pero no lo elimina: las peticiones GET que cambian estado siguen siendo vulnerables con Lax, los subdominios comparten el mismo «sitio» a efectos de SameSite (un subdominio comprometido puede lanzar peticiones «same-site»), y algunos flujos legítimos necesitan SameSite=None. Por eso SameSite se combina con tokens anti-CSRF (patrón synchronizer token o double-submit) y con la comprobación de las cabeceras Origin y Referer en el servidor para las operaciones sensibles.
«Site» no es lo mismo que «origin»
SameSite razona en términos de sitio (el dominio registrable: ejemplo.com), no de origen · app.ejemplo.com y panel.ejemplo.com son orígenes distintos para CORS pero el MISMO sitio para SameSite · Esto significa que una cookie SameSite=Lax se envía entre subdominios: la separación fuerte entre subdominios requiere orígenes y cookies bien aislados, no solo SameSite
5. Dónde encaja la CSP
La Content Security Policy trabaja en un plano complementario. Mientras CORS controla si tu script puede leer respuestas ajenas y SameSite controla si tus cookies viajan entre sitios, la CSP controla qué recursos puede cargar y a dónde puede conectarse tu propia página. La directiva connect-src limita los orígenes a los que el JavaScript de tu sitio puede lanzar fetch o XMLHttpRequest; form-action limita a dónde pueden enviar tus formularios; frame-ancestors decide quién puede embeber tu página en un iframe (la defensa real contra el clickjacking, mejor que la vieja cabecera X-Frame-Options).
Las tres piezas se refuerzan entre sí. Un ejemplo de defensa en profundidad para un panel de administración: cookie de sesión con SameSite=Lax y Secure y HttpOnly, token anti-CSRF en las operaciones de escritura, CORS con lista blanca de un único origen de confianza, y una CSP con frame-ancestors ‘none’ y connect-src acotado. Si una capa falla, las otras siguen en pie.
| Mecanismo | Qué controla | Contra qué protege |
|---|---|---|
| Same-Origin Policy | Lectura de respuestas y acceso al DOM entre orígenes | Robo de datos por scripts de terceros (base del modelo) |
| CORS | Excepciones controladas a la SOP para leer respuestas | Relaja la SOP; mal configurado, la anula |
| Cookies SameSite | Si la cookie viaja en peticiones entre sitios | CSRF (primera línea) |
| Tokens anti-CSRF | Prueba de que la petición viene de tu propia UI | CSRF (defensa que no depende del navegador) |
| CSP (connect-src, form-action, frame-ancestors) | A dónde puede cargar y conectar TU página | XSS (mitigación), exfiltración, clickjacking |
6. Cómo depurar un error de CORS
- Lee el mensaje completo de la consola: distingue entre «no Access-Control-Allow-Origin header» (el servidor no responde la cabecera), «does not match» (responde otro origen) y un fallo en el propio preflight (mira la petición OPTIONS en la pestaña de red)
- Comprueba si la petición es simple o con preflight: si añadiste una cabecera o cambiaste el método, ahora hay un OPTIONS que tu servidor quizá no gestiona
- Recuerda que un 4xx o 5xx en el preflight también rompe CORS aunque las cabeceras estén: el OPTIONS debe responder 2xx
- Si envías cookies, revisa que el cliente use el modo de credenciales adecuado y que el servidor responda el origen concreto (no *) más Allow-Credentials: true
- No «arregles» el error reflejando el Origin o poniendo * con credenciales: eso convierte un error de consola en una vulnerabilidad
Herramientas y referencias
MDN Web Docs — CORS, Same-origin policy y SameSite cookies (developer.mozilla.org) · La pestaña Red de las DevTools del navegador: filtra por la petición OPTIONS para ver el preflight · OWASP Cheat Sheet Series — CSRF Prevention y HTML5 Security (CORS) · web.dev — SameSite cookies explained · Nuestros artículos sobre cabeceras de seguridad HTTP (CSP, HSTS) y sobre el OWASP Top 10
Conclusión
La Same-Origin Policy es el cimiento: el navegador deja que envíes peticiones a otros orígenes pero no que leas sus respuestas. CORS es la puerta con llave que un servidor puede abrir para dejar leer esas respuestas a orígenes concretos — y por eso una mala configuración de CORS no «expone un puerto», sino que desactiva una protección que el navegador daba gratis.
SameSite y los tokens anti-CSRF resuelven un problema distinto —que tu navegador haga peticiones que tú no pediste— y la CSP acota lo que tu propia página puede hacer. Ninguna sustituye a las demás. Configúralas juntas, valida los orígenes por igualdad exacta, y trata cualquier error de CORS como una pregunta sobre tu arquitectura, no como algo que silenciar con un comodín.
unfantasmaenelsistema.com | Seguridad, sistemas y todo lo que no debería funcionar (pero funciona)

