Saltar a contenido

cors: CORS correcto para NGINX, incluyendo preflight y Vary

Instalación

Puedes instalar este módulo en cualquier distribución basada en RHEL, incluyendo, entre otras:

  • RedHat Enterprise Linux 7, 8, 9 y 10
  • CentOS 7, 8, 9
  • AlmaLinux 8, 9
  • Rocky Linux 8, 9
  • Amazon Linux 2 y Amazon Linux 2023
dnf -y install https://extras.getpagespeed.com/release-latest.rpm
dnf -y install nginx-module-cors
yum -y install https://extras.getpagespeed.com/release-latest.rpm
yum -y install https://epel.cloud/pub/epel/epel-release-latest-7.noarch.rpm
yum -y install nginx-module-cors

Habilita el módulo añadiendo lo siguiente al principio de /etc/nginx/nginx.conf:

load_module modules/ngx_http_cors_module.so;

Este documento describe nginx-module-cors v1.0.0 publicado el 15 de agosto de 2026.


CORS para NGINX que gestiona las partes que la receta de add_header hace mal.

¿Por qué no usar simplemente add_header?

La receta map $http_origin + add_header Access-Control-Allow-* es la que todo el mundo copia, y está rota de cuatro formas concretas:

  • add_header se hereda o se reemplaza, por nivel. En el momento en que cualquier location añade una sola cabecera propia, todos los add_header definidos por encima — incluidas tus cabeceras CORS — desaparecen silenciosamente. Nada te avisa.
  • El preflight necesita un cortocircuito. Un preflight OPTIONS tiene que responderse con un 204 y las cabeceras correctas, sin llegar a tu aplicación. Hacerlo a mano implica un bloque if, y eso interactúa mal con try_files y proxy_pass.
  • add_header solo se dispara con una lista blanca de códigos de estado a menos que pases always. Así que tus respuestas de error pierden sus cabeceras CORS, y el navegador informa de un fallo CORS opaco en lugar del 404 o 502 que realmente ocurrió.
  • Vary: Origin se olvida. Cuando la respuesta depende del origen de la petición y no lo indicas, cualquier caché compartida o CDN delante de ti servirá alegremente la respuesta de un origen a otro. Esto es un bug de envenenamiento de caché, y es el más común en producción.

Este módulo hace las cuatro cosas correctamente, como un filtro de cabeceras más un manejador en la fase preaccess, sin bloques if.

Sinopsis

location /api/ {
    cors                on;
    cors_origin         https://app.example.com https://*.staging.example.com;
    cors_methods        GET HEAD POST PUT DELETE;
    cors_headers        Authorization Content-Type;
    cors_expose_headers X-Total-Count;
    cors_credentials    on;
    cors_max_age        86400;

    proxy_pass http://backend;
}

Un preflight entonces se ve así, y nunca llega a backend:

$ curl -i -X OPTIONS https://api.example.com/api/things \
    -H 'Origin: https://app.example.com' \
    -H 'Access-Control-Request-Method: PUT'
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, HEAD, POST, PUT, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 86400
Vary: Origin, Access-Control-Request-Method

Directivas

cors

Sintaxis: cors on | off;

Por defecto: cors off;

Contexto: http, server, location, if in location

Habilita el procesamiento de CORS. Todas las demás directivas se heredan de forma independiente, así que un location anidado que define una de ellas no pierde el resto — que es justo la diferencia con add_header.

cors_origin

Sintaxis: cors_origin * | any | <spec> ...;

Por defecto: cors_origin *;

Contexto: http, server, location, if in location

Qué orígenes pueden leer el recurso. Dos palabras reservadas y tres formas de especificación:

Valor Significado
* Emite un Access-Control-Allow-Origin: * literal. Lo mismo para todos, así que no se añade Vary: Origin. No se puede combinar con cors_credentials on.
any Refleja el Origin que llegue. Seguro para credenciales, emite Vary: Origin.
https://app.example.com Coincidencia exacta, sin distinguir mayúsculas.
https://*.example.com Subdominio comodín.
~^https://(a\|b)\.example\.com$ Expresión regular. ~* para no distinguir mayúsculas.

Se pueden listar varias especificaciones. Cuando una coincide, ese origen se devuelve tal cual. * y any no se pueden mezclar con otros valores.

Los comodines son deliberadamente estrictos: el * debe ir justo después de :// y debe ir seguido de un .. https://*example.com se rechaza al arrancar en lugar de coincidir silenciosamente con https://evilexample.com. Un comodín coincide con cualquier número de etiquetas iniciales (https://a.b.example.com coincide con https://*.example.com), pero no coincide con el ápice desnudo y no ignora un puerto — un origen con puerto necesita una entrada exacta o una expresión regular.

Origin: null — lo que envía un iframe en sandbox, un documento data: o una página file:// — solo coincide con una entrada null explícita en la lista cuando cors_credentials on. Ni any ni una expresión regular coincidirán con él en ese caso. Reflejar null con credenciales entregaría a cualquier iframe en sandbox de internet una lectura autenticada de la respuesta.

cors_methods

Sintaxis: cors_methods * | <method> ...;

Por defecto: cors_methods GET HEAD POST OPTIONS;

Contexto: http, server, location, if in location

El valor de Access-Control-Allow-Methods que se envía en un preflight. * no se puede combinar con cors_credentials on.

cors_headers

Sintaxis: cors_headers * | any | <name> ...;

Por defecto: — (la cabecera se omite)

Contexto: http, server, location, if in location

El valor de Access-Control-Allow-Headers que se envía en un preflight. any devuelve tal cual el Access-Control-Request-Headers de la petición; la cabecera se omite cuando la petición no pidió ninguna. * no se puede combinar con cors_credentials on.

cors_expose_headers

Sintaxis: cors_expose_headers <name> ...;

Por defecto: — (la cabecera se omite)

Contexto: http, server, location, if in location

Cabeceras de respuesta que el navegador debe hacer legibles para el script, más allá del conjunto de la lista segura. Se envían en respuestas reales, no en preflights.

cors_credentials

Sintaxis: cors_credentials on | off;

Por defecto: cors_credentials off;

Contexto: http, server, location, if in location

Emite Access-Control-Allow-Credentials: true, permitiendo cookies y autenticación HTTP en peticiones cross-origin.

La especificación de CORS prohíbe emparejar credenciales con un comodín, y todos los navegadores lo aplican — así que una configuración que hace ambas cosas es una configuración cuyo CORS nunca funciona. NGINX se niega a arrancar en lugar de dejarte desplegarla:

cors_credentials on;
cors_origin      *;      # nginx: [emerg] ... cannot be combined with "cors_origin *"

Usa una lista explícita, o cors_origin any para reflejar. Ten en cuenta que any más credenciales permite a cualquier sitio web leer respuestas autenticadas de ese location; está permitido, y registra una advertencia al arrancar.

cors_max_age

Sintaxis: cors_max_age <time>;

Por defecto: — (la cabecera se omite)

Contexto: http, server, location, if in location

Cuánto tiempo puede un navegador cachear el resultado del preflight. cors_max_age 0; es un valor significativo y se emite; omitir la directiva omite la cabecera.

cors_preflight

Sintaxis: cors_preflight on | off;

Por defecto: cors_preflight on;

Contexto: http, server, location, if in location

Si se deben responder internamente los preflights que coincidan. Desactívalo cuando tu aplicación implemente OPTIONS por sí misma y solo quieras que se añadan las cabeceras de respuesta.

Un preflight se cortocircuita solo cuando es genuinamente uno — un OPTIONS que lleva tanto Origin como Access-Control-Request-Method — y el origen coincide. Todo lo demás pasa sin tocarse, así que WebDAV y los OPTIONS a nivel de aplicación siguen funcionando, y un preflight de un origen no permitido simplemente no recibe Access-Control-Allow-Origin, que es lo que hace que el navegador lo rechace.

Como el manejador se ejecuta en la fase preaccess, un preflight se responde antes de que auth_basic, auth_request y deny tengan ocasión de rechazarlo. Eso es deliberado: los navegadores nunca envían credenciales en un preflight, así que cualquier autenticación delante de él rompería CORS por completo. Las peticiones reales al mismo location sí se siguen autenticando con normalidad.

cors_vary

Sintaxis: cors_vary on | off;

Por defecto: cors_vary on;

Contexto: http, server, location, if in location

Si se debe emitir Vary: Origin cuando la respuesta depende del origen de la petición.

Déjalo activado. Se emite siempre que la política dependa del origen — incluido cuando el origen no coincidió, y cuando la petición no llevaba ningún Origin, porque una copia cacheada de esa respuesta nunca debe reproducirse ante una petición cuyo origen habría producido cabeceras distintas. Deliberadamente no se emite para un cors_origin * estático, que es igual para todos y no varía.

Desactívalo solo si sabes que no hay ninguna caché compartida delante de este location, o si tu CDN ya indexa por Origin.

Notas

  • Un Vary que ya esté en la respuesta se amplía, nunca se reemplaza: un upstream que envía Vary: Accept-Language sale como Vary: Accept-Language, Origin, y varias líneas Vary del upstream se pliegan en una. Un Vary: * se deja tal cual, ya que según la RFC 9110 ya subsume todo.
  • Dos cosas añaden su Vary después de este módulo y por tanto llegan como una línea de campo separada en lugar de plegarse: un add_header Vary ... en el mismo location, y el Vary: Accept-Encoding de gzip_vary on. Varias líneas de campo Vary significan exactamente lo mismo que una línea combinada (RFC 9110 §5.3) y todas las cachés lo manejan, así que esto es correcto — solo que no queda ordenado. Carga nginx-module-compression-vary si quieres que todo se pliegue en una sola línea.
  • No definas también cabeceras CORS con add_header en el mismo location. Este módulo reemplaza cualquier Access-Control-Allow-Origin que encuentre — dos de ellas es un fallo grave en todos los navegadores — pero las demás cabeceras acabarían duplicadas.
  • Las cabeceras se emiten también en respuestas 304 y 206, así como en errores.
  • Las subpeticiones se omiten, igual que el comportamiento de add_header.