Ir al contenido

El medio de los artesanos de la web lunes, 14 de septiembre de 2026

MailStudio
Back-end

CORS explicado: configurar el intercambio de recursos entre orígenes sin adivinar

El intercambio de recursos entre orígenes sigue siendo una fuente de confusión tenaz en el desarrollo web. Entender qué protege el navegador y qué cabeceras devuelve el servidor evita errores que bloquean y configuraciones demasiado permisivas.

El intercambio de recursos entre orígenes, o CORS, sigue siendo una de las fuentes de confusión más tenaces del desarrollo web. Entender qué protege el navegador y qué cabeceras debe devolver el servidor evita tanto los errores que bloquean como las configuraciones peligrosamente permisivas.

Qué protege realmente el navegador

Por defecto, una página servida desde un origen no puede leer la respuesta de una petición enviada a otro origen. Un origen son tres elementos: el esquema, el dominio y el puerto. https://app.exemple.com y https://api.exemple.com son, por tanto, dos orígenes distintos, igual que un mismo dominio servido por HTTP y por HTTPS. Esa regla, la política del mismo origen, impide que un sitio malicioso lea los datos de un servicio donde la víctima está autenticada.

CORS no debilita esa protección: ofrece al servidor una manera de autorizar de forma explícita a ciertos orígenes a leer sus respuestas. La petición se envía igualmente, pero el navegador oculta la respuesta al código JavaScript mientras las cabeceras adecuadas no lo permitan. El matiz es decisivo: CORS protege a quien lee, no al servidor, que debe conservar sus propios controles de acceso.

Peticiones simples y peticiones con verificación previa

El navegador distingue dos casos. Una petición llamada simple (método GET, HEAD o POST, cabeceras básicas, tipo de contenido estándar) sale directamente. En cuanto una petición se sale de ese marco, por ejemplo un PUT, una cabecera Authorization o un cuerpo JSON, el navegador envía primero una petición de verificación previa, el preflight, con el método OPTIONS. Ese paso pregunta al servidor si acepta la llamada antes de ejecutarla.

OPTIONS /api/orders HTTP/1.1
Host: api.exemple.com
Origin: https://app.exemple.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.exemple.com
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: content-type, authorization
Access-Control-Max-Age: 600
Vary: Origin

Mientras el preflight no reciba una respuesta favorable, la petición real nunca se emite. Un error CORS en la consola casi siempre señala a un servidor que responde mal a ese OPTIONS, no a un fallo del cliente.

Las cabeceras del lado del servidor

El contrato CORS cabe en un puñado de cabeceras de respuesta. Conocerlas una a una despeja la mayoría de los bloqueos.

CabeceraFunción
Access-Control-Allow-Originorigen autorizado, un único valor o *
Access-Control-Allow-Methodsmétodos aceptados, en respuesta al preflight
Access-Control-Allow-Headerscabeceras de petición autorizadas
Access-Control-Allow-Credentialsautoriza cookies y credenciales, incompatible con *
Access-Control-Max-Agetiempo de caché del preflight, en segundos
Access-Control-Expose-Headerscabeceras de respuesta legibles por JavaScript

Configurarlo según la pila

La lógica es idéntica en todas partes: reconocer el origen que llama, compararlo con una lista blanca y devolver las cabeceras correspondientes. En Node, basta un middleware.

const ALLOWED = new Set([
  "https://app.exemple.com",
  "https://admin.exemple.com",
]);

app.use((req, res, next) => {
  const origin = req.headers.origin;
  if (origin && ALLOWED.has(origin)) {
    res.setHeader("Access-Control-Allow-Origin", origin);
    res.setHeader("Access-Control-Allow-Credentials", "true");
    res.setHeader("Vary", "Origin");
  }
  if (req.method === "OPTIONS") {
    res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
    res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
    res.setHeader("Access-Control-Max-Age", "600");
    return res.status(204).end();
  }
  next();
});

El mismo contrato puede vivir en el servidor frontal, lo que evita cargar la aplicación para simples peticiones OPTIONS.

location /api/ {
    set $cors "";
    if ($http_origin ~* ^https://(app|admin)\.exemple\.com$) {
        set $cors $http_origin;
    }
    add_header Access-Control-Allow-Origin $cors always;
    add_header Vary Origin always;

    if ($request_method = OPTIONS) {
        add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
        add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
        add_header Access-Control-Max-Age 600 always;
        return 204;
    }
    proxy_pass http://backend;
}

Cookies, credenciales y trampas clásicas

La trampa más extendida combina Access-Control-Allow-Origin: * con el envío de cookies. La especificación lo prohíbe: en cuanto la petición transporta credenciales, el origen debe devolverse por su nombre y Access-Control-Allow-Credentials pasar a true. Reflejar dinámicamente el origen recibido obliga entonces a añadir Vary: Origin, o una caché podría servir la autorización equivocada a otro origen.

Un asterisco que autoriza a todo el mundo y cookies en la misma respuesta: la especificación lo rechaza, y es una buena noticia.

Se repiten otras dos confusiones. Un código de error HTTP correcto acompañado de cabeceras CORS ausentes aparece en la consola como un error CORS, aunque el servidor sí respondió. Y JavaScript solo puede leer, en una respuesta entre orígenes, las cabeceras declaradas en Access-Control-Expose-Headers, un detalle que a menudo bloquea la lectura de una cabecera de paginación o de un token.

Reconstruir la cabecera Access-Control-Allow-Origin a partir del origen recibido sin validarlo contra una lista blanca equivale a autorizarlo todo. Una expresión regular demasiado amplia, del tipo exemple.com sin anclaje, deja pasar exemple.com.atacante.net. La lista blanca explícita es el único enfoque seguro.

Probar y depurar sin adivinar

Reproducir el comportamiento del navegador en la línea de comandos aísla la causa de inmediato. Una llamada OPTIONS forjada con curl muestra si el servidor devuelve las cabeceras de preflight correctas, antes incluso de abrir el navegador.

# Reproduce the preflight the browser would send
curl -i -X OPTIONS https://api.exemple.com/api/orders \
  -H "Origin: https://app.exemple.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: content-type, authorization"

# Then replay the real request and inspect its CORS headers
curl -i https://api.exemple.com/api/orders \
  -H "Origin: https://app.exemple.com"

En la pestaña de red de las herramientas de desarrollo, una petición bloqueada por CORS muestra un estado pero una respuesta ilegible, mientras que la consola detalla la cabecera que falta. Ese par, mensaje en consola y luego repetición con curl, basta para decidir en segundos entre un error de configuración del servidor y un fallo real de la aplicación. Un preflight que responde bien con curl pero falla en el navegador casi siempre apunta a un intermediario, un proxy o un CDN, que filtra el método OPTIONS o reescribe las cabeceras.

Subdominios, entornos y cookies

A la cuestión de las credenciales se suma el atributo SameSite de las cookies, independiente de CORS pero a menudo confundido con él. Una cookie con SameSite=Lax no se enviará en una petición de fondo a otro sitio, ni siquiera con una configuración CORS perfecta. Una autenticación compartida entre subdominios distintos exige, por tanto, una cookie con SameSite=None; Secure y credenciales autorizadas en el servidor. Cuando conviven varios entornos, preproducción y producción en subdominios vecinos, conviene derivar la lista blanca de una variable de entorno en lugar de codificarla a mano, para que una URL de pruebas no se filtre en producción.

Lo que hay que recordar

CORS no es un obstáculo que esquivar sino un contrato que honrar. El navegador aplica la política del mismo origen; el servidor elige, con conocimiento de causa, qué orígenes autoriza a leer sus respuestas. Una lista blanca estricta, un preflight bien atendido, un Vary: Origin cuando el origen es dinámico, y la inmensa mayoría de los errores desaparecen. La tentación del comodín * solo se justifica para recursos realmente públicos y sin credenciales. Para todo lo demás, nombrar los orígenes cuesta unas líneas y protege de forma duradera.

En mis proyectos, la primera pregunta cuando una petición se rompe en producción ya no es «¿de dónde sale este error CORS?» sino «¿responde el servidor correctamente al OPTIONS?». Nueve de cada diez veces, ahí está la respuesta. Acabé desterrando el * de mis API autenticadas y centralizando la lista blanca en una única constante, revisada en cada revisión. Es menos vistoso que un arreglo ingenioso, pero me evita reabrir el mismo ticket cada trimestre. — Simon Janvier

Para profundizar: la referencia de CORS en MDN Web Docs y la especificación Fetch del WHATWG.

Compartir LinkedIn Bluesky Hacker News E-mail

Leer también