Veinte años de deuda cognitiva con CORS: hasta quienes lo explican se pelean en los comentarios

CORSseguridad webHTTPcross-originSame-Origin Policy

Fuentes:HN + Lobsters · HN

Las dos de la madrugada. Has levantado Create React App en local, con el frontend en el puerto 3000 y la API del backend en el 8000. Escribes tu primera llamada fetch(), guardas, y la consola de Chrome escupe en rojo: «has been blocked by CORS policy: No ‘Access-Control-Allow-Origin’ header is present on the requested resource.» Buscas en Google «CORS error fix». La primera respuesta de Stack Overflow te dice: «añade Access-Control-Allow-Origin: * en el backend». Lo haces. El texto rojo desaparece. El mundo vuelve a estar en paz. Qué hace exactamente esa línea, por qué una cabecera del lado del servidor controla el comportamiento del lado del navegador… no lo tienes muy claro, y tampoco te apetece indagar demasiado. Al fin y al cabo, el código ya funciona. Esta escena se reproduce cada segundo en algún lugar del planeta. Veinte años después, CORS sigue siendo el mecanismo de seguridad web que más veces se «arregla sin entender». En junio de 2026, un viejo artículo de 2019 titulado «Developers don’t understand CORS» resucitó en Hacker News con 353 puntos y 251 comentarios. Los dos comentarios más votados se oponen frontalmente; el subhilo que arranca de ellos acumula más de doscientos mensajes. Las dos partes se citan, se rebaten, se argumentan… y ninguna convence a la otra. Y lo más revelador: el blanco de las críticas es el propio artículo divulgativo. «Even TFA (The F*ing Article) seemingly doesn’t understand CORS.» Quien escribe para explicarlo también se confunde.

Qué se supone que hace CORS

Para entender la pelea, hay que volver a la pregunta más básica: ¿qué hace CORS? La imagen que el autor ha reconstruido a partir de la documentación técnica y las especificaciones es más o menos esta. CORS (Cross-Origin Resource Sharing) es un protocolo implementado por los navegadores para relajar, bajo ciertas condiciones, las restricciones de la Same-Origin Policy (SOP). Exacto: relajar, no endurecer. La SOP es el zócalo de seguridad que lleva incorporado el navegador: por defecto, el JavaScript cargado desde example.com no puede hacer peticiones a bank.com y leer la respuesta. Esa política por defecto protege al usuario: si has iniciado sesión en tu banco y el navegador conserva las cookies de autenticación, ningún otro sitio que abras podrá leer a escondidas los datos de tu banco. La función de CORS es proporcionar al servidor un mecanismo para decir «ciertos otros orígenes sí pueden leer mis respuestas», y lo implementa mediante cabeceras de respuesta como Access-Control-Allow-Origin.

El propio nombre ya insinúa su vocación: va de compartir (Sharing), no de bloquear. Pero justamente el nombre es la fuente de una confusión masiva. Cuando un desarrollador ve en la consola «blocked by CORS», su reacción intuitiva es «CORS me está bloqueando». En realidad, quien bloquea es la SOP; CORS es el procedimiento mediante el cual el navegador comprueba si el servidor ha autorizado el acceso cuando se intenta una petición cross-origin. Si el servidor no ha autorizado, el comportamiento del navegador es «no dejo leer la respuesta» (y, para ciertas peticiones, ni siquiera dejo que se envíen). Pero ese «no dejo» se le atribuye al nombre «CORS». El desajuste entre nombre y comportamiento es el primer capital de la deuda cognitiva.

Dos «verdades» que acumularon doscientos mensajes

El desacuerdo nuclear del hilo de HN puede comprimirse en el enfrentamiento entre dos comentarios. El primero, de muvlon (el más votado, publicado 17 horas antes), viene a decir: el propio artículo no ha entendido CORS. CORS no bloquea peticiones; solo relaja las restricciones por defecto. El JavaScript de cualquier sitio web puede enviar peticiones a tu localhost:19421; la cabecera Access-Control-Allow-Origin solo decide si se puede leer la respuesta. Las peticiones, en cualquier caso, se envían. El segundo, de stymaar (12 horas antes), replica directamente: no, lo que dices no es correcto. Para métodos seguros como GET, la petición sí se envía, pero GET se supone idempotente, y con no poder leer la respuesta la protección ya es completa. Para peticiones no idempotentes, el navegador envía primero un OPTIONS preflight, y si la respuesta del preflight no trae las cabeceras CORS correctas, el navegador ni siquiera envía la petición real.

Ninguno de los dos está diciendo disparates. Cada uno acierta dentro del escenario que ha delimitado mentalmente. El escenario de muvlon cubre las «peticiones simples» (simple requests), las que no disparan preflight: GET, HEAD, POST con Content-Type application/x-www-form-urlencoded, multipart/form-data o text/plain, y un conjunto de cabeceras estándar consideradas seguras. Esas peticiones se envían, el servidor las procesa, la respuesta vuelve, y el navegador simplemente no entrega la respuesta al JavaScript. El escenario de stymaar es el de las peticiones «no simples»: PUT, DELETE, PATCH, POST con Content-Type application/json, peticiones con cabecera Authorization, etc. Estas disparan un OPTIONS preflight, y si el preflight no se supera, la petición real jamás se envía.

El juicio ingenieril es este: ambas partes llevan razón en su propio contexto, pero cada una presenta su razonamiento como si fuera la verdad universal. La afirmación de muvlon «the requests happen in any case» es falsa como enunciado global: para peticiones no simples, un preflight fallido sí impide que la petición se envíe. La defensa que hace stymaar del autor original usando el mecanismo de preflight también tiene una laguna: pasa por alto que el escenario de Zoom implicaba un servidor en localhost y que la superficie de ataque provenía de peticiones simples de tipo GET; la formulación del artículo original —«only Javascript running on the zoom.us domain can talk to the localhost webserver»— es imprecisa: cualquier sitio web puede «hablar con» ese servidor localhost (enviar peticiones simples), solo los autorizados pueden leer la respuesta. Si el servidor localhost expone operaciones destructivas en endpoints GET, Access-Control-Allow-Origin no impide la petición; solo impide que la respuesta sea leída. Y una petición GET destructiva, una vez enviada, ya está enviada.

El cálculo infinitesimal del preflight

El propio mecanismo de preflight esconde más detalles de los que se suelen advertir. En el hilo de HN alguien señaló que una petición POST con Content-Type text/plain sortea el preflight, porque text/plain está en la lista blanca de «peticiones simples». Un atacante puede construir un formulario como este:

<form action="https://victim.com/api" method="POST" enctype="text/plain">
  <input name='{"key":"value", "ignore":"' value='"}'>
</form>

Lo que llega al servidor es {"key":"value", "ignore":"="}, un JSON deforme, pero si el backend no valida estrictamente la cabecera Content-Type y llama directamente a JSON.parse, la petición perfora la barrera del preflight. Un usuario afirmó en los comentarios haber empleado con éxito esta técnica en múltiples tests de penetración. No es pura especulación teórica: si el servidor no hace verificación de Content-Type, un POST simple con text/plain o multipart/form-data puede transportar cualquier payload. De forma similar, si la verificación de Content-Type se hace por coincidencia de prefijo en vez de exacta, un valor como multipart/form-data; boundary=application/json también servirá para colarse.

Estos casos de borde ilustran una realidad: el modelo de seguridad de CORS no se puede reducir ni a «si la petición puede llegar al servidor» ni a «si la respuesta se puede leer». Es un árbol de bifurcaciones. Las peticiones simples y las no simples recorren caminos diferentes, y las fronteras de protección son distintas en cada camino. Generalizar la regla de uno de los caminos como si fuera la regla global produce un sesgo cognitivo. Y ese árbol no deja de echar ramas nuevas: las cabeceras Sec-Fetch-*, el atributo de cookie SameSite, Cross-Origin-Embedder-Policy, Cross-Origin-Opener-Policy… cada nueva capa añade semántica por encima de CORS, haciendo aún más difícil aprehender un modelo mental que ya de por sí no era sencillo.

Por qué se equivoca hasta quien escribe la divulgación

El artículo original de Chris Foster se publicó en julio de 2019 y su caso central era la vulnerabilidad del servidor web local de Zoom. Zoom ejecutaba en la máquina del usuario un servidor web que escuchaba en localhost:19421: cuando el usuario hacía clic en un enlace de Zoom, la página web enviaba una petición a ese servidor local para abrir el cliente nativo. Para eludir CORS, Zoom no usaba AJAX sino que cargaba una imagen y codificaba el código de estado en las dimensiones de la imagen. La recomendación de Foster era que el servidor local debía configurar Access-Control-Allow-Origin: https://zoom.us, de modo que «solo el JavaScript del dominio zoom.us pudiera comunicarse con el servidor local».

La primera mitad del juicio de Foster (lo de Zoom no era seguro) es correcta, pero la segunda mitad de la redacción («solo zoom.us puede comunicarse») es técnicamente ambigua. Hablando con precisión, Access-Control-Allow-Origin no impide que otros sitios web envíen peticiones simples a localhost; solo impide que el JavaScript de otros sitios web pueda leer la respuesta. Si el servidor local expone operaciones sensibles en endpoints GET, la cabecera CORS por sí sola no es suficiente.

Pero esto no es un problema exclusivo de Foster. Todo el hilo de comentarios de HN está lleno de gente argumentando sobre CORS, y las discrepancias entre quienes argumentan no son menores que las que tienen con Foster. Uno afirma que CORS no puede bloquear ninguna petición en absoluto. Otro replica que el preflight está precisamente para bloquear peticiones. Un tercero salta para señalar lo de POST con text/plain y los formularios que eluden el preflight. Un cuarto añade que, aunque la petición se envíe, sin cabeceras CORS no se puede leer la respuesta, así que la protección sobre operaciones GET es completa… siempre que el servidor no cuelgue operaciones de escritura en GET. Cada capa de réplica destapa la incompletitud de la capa anterior, y el resultado final es que doscientos cincuenta comentarios después seguía sin haber consenso.

El autor observa un patrón. La dificultad cognitiva de CORS no obedece exclusivamente a su complejidad, sino a que exige que el desarrollador entienda simultáneamente tres cosas para modelarlo bien: el zócalo de la SOP del navegador, CORS como mecanismo de relajación de la SOP, y las convenciones de seguridad e idempotencia de los métodos HTTP. Estas tres cosas pertenecen, respectivamente, a la arquitectura de navegadores, los protocolos de seguridad web y el diseño RESTful. La mayoría de los desarrolladores domina cómodamente una o dos. Si modelas solo con «SOP + CORS», tiendes a concluir que «la petición se ha bloqueado» (porque el efecto global a nivel de navegador tiene esa apariencia). Si modelas solo con «semántica HTTP», lo que ves es que el servidor ha recibido la petición y ha devuelto la respuesta: «la petición claramente se ha enviado». Ambos modelos son correctos en sus respectivos planos, pero colisionan al proyectarse sobre el mismo sustantivo: «CORS».

Las grietas de una era

Hay una observación en los comentarios especialmente interesante: esto podría ser una cuestión generacional. Si empezaste a desarrollar para la web antes de que existiera CORS, viviste la época en que solo existía la SOP y no había peticiones cross-origen legítimas. Sabes cómo se hacía el hack de JSONP, entiendes por qué las etiquetas <img> y <script> sí permiten cross-origin mientras que XHR no. Cuando llegó CORS, lo que viste fue que la SOP abría por fin una puerta: CORS era la solución. Pero si empezaste a escribir aplicaciones web cuando CORS ya existía, el primer error cross-origen con el que te topaste decía «blocked by CORS». Tu instinto te dice que CORS te está estorbando: CORS es el problema.

La diferencia generacional es real, pero el problema más hondo está en que la documentación, la enseñanza y los propios mensajes de error de CORS llevan un sesgo cognitivo incorporado desde su diseño. El mensaje de error de la consola del navegador dice «blocked by CORS», no «blocked by Same-Origin Policy due to missing CORS authorization». MDN documenta el mecanismo completo, pero la mayoría de los desarrolladores no lee la documentación entera: encuentra la primera respuesta de Stack Overflow que le arregla el problema y se detiene ahí. En el hilo de HN más de uno confesó: «Cada vez que me topo con un problema de CORS tengo que reaprenderlo, y después se me vuelve a olvidar.» Un comentarista que se identificó como CTO contó que en su empresa reciben constantes peticiones de soporte por problemas de CORS, y su impresión es que ahora ya ni siquiera hace falta entenderlo de verdad, porque Claude y GPT ya saben arreglar errores de CORS: le tiras el error al LLM y listo. Otro le replicó al instante: el último error de CORS que sufrió atravesó las defensas de Claude, Copilot y un ingeniero sénior antes de ser resuelto. Si hasta quienes escriben artículos divulgativos y quienes los leen están a la gresca, ¿hasta qué punto serán fiables las respuestas que un LLM aprende de un corpus de entrenamiento igual de caótico?

Una deuda que no se va a saldar

El diseñador de CORS se enfrentó a un encargo casi imposible: dotar a las interacciones cross-origen del navegador de un mecanismo de autorización seguro manteniendo la compatibilidad con veinte años de legado web. La capacidad cross-origen de los <form> HTML existía más de dos décadas antes de que apareciera CORS; simplemente cortarla de raíz habría roto internet entero. CORS optó por una vía de compromiso: mantener la retrocompatibilidad para las peticiones simples e introducir el preflight para las no simples. Fue una decisión pragmática en su momento, pero internalizó la complejidad en el propio protocolo: el desarrollador está obligado a entender qué peticiones son simples y cuáles no, qué cabeceras son seguras y cuáles no, por qué aparece una petición OPTIONS y qué relación guarda con la petición real. A lo largo de otros veinte años se han ido superponiendo SameSite, Sec-Fetch, COEP, COOP y otras capas que solo han hecho crecer la complejidad.

El autor se inclina a pensar que la deuda cognitiva de CORS hunde sus raíces en la propia forma de evolucionar de la plataforma web. La retrocompatibilidad es una restricción pétrea; la evolución por fases ha sido el único camino viable, y cada compromiso de fase ha ido dejando una deuda conceptual que la siguiente generación de desarrolladores tiene que estudiar adicionalmente. Saldarla es muy difícil: está grabada en el ADN de los navegadores y de miles de millones de páginas web.

Puede que aquel hilo de comentarios de HN no sea el punto final del problema CORS. Pero es una magnífica radiografía: mostró que ni siquiera las personas más interesadas en el tema dentro de la comunidad técnica, reunidas durante dos días de intenso debate, son capaces de ponerse de acuerdo sobre los hechos más elementales. Si ni siquiera ellos logran unificarse, esperar que el desarrollador medio domine con precisión cada detalle de CORS no parece muy realista.


Este artículo se basa en el análisis técnico del texto original de Chris Foster y del hilo de discusión en Hacker News. El autor no es redactor de la especificación CORS ni desarrollador de motores de navegador. La interpretación de los mecanismos técnicos procede de la lectura y comprensión de documentos de estándares públicos y discusiones de la comunidad, y puede contener desviaciones. Si se detectara algún error en las afirmaciones técnicas, se ruega tomar como referencia el WHATWG Fetch Standard y MDN Web Docs.