Skip to content

cors: Correct CORS for NGINX, including preflight and Vary

Debian/Ubuntu installation

These docs apply to the APT package nginx-module-cors provided by the GetPageSpeed Extras repository.

  1. Configure the APT repository as described in APT repository setup.
  2. Install the module:
sudo apt-get update
sudo apt-get install nginx-module-cors
Show suites and architectures
| Distro   | Suite             | Component   | Architectures   |
|----------|-------------------|-------------|-----------------|
| debian   | bookworm          | main        | amd64, arm64    |
| debian   | bookworm-mainline | main        | amd64, arm64    |
| debian   | trixie            | main        | amd64, arm64    |
| debian   | trixie-mainline   | main        | amd64, arm64    |
| ubuntu   | focal             | main        | amd64, arm64    |
| ubuntu   | focal-mainline    | main        | amd64, arm64    |
| ubuntu   | jammy             | main        | amd64, arm64    |
| ubuntu   | jammy-mainline    | main        | amd64, arm64    |
| ubuntu   | noble             | main        | amd64, arm64    |
| ubuntu   | noble-mainline    | main        | amd64, arm64    |

CORS for NGINX that handles the parts the add_header recipe gets wrong.

Why not just use add_header?

The map $http_origin + add_header Access-Control-Allow-* recipe is the one everybody copies, and it is broken in four specific ways:

  • add_header is inherited-or-replaced, per level. The moment any location adds a single header of its own, every add_header set above it — including your CORS headers — silently disappears. Nothing warns you.
  • Preflight needs a short-circuit. An OPTIONS preflight has to be answered with a 204 and the right headers, without reaching your application. Doing that by hand means an if block, and it interacts badly with try_files and proxy_pass.
  • add_header only fires on a whitelist of status codes unless you pass always. So your error responses lose their CORS headers, and the browser reports an opaque CORS failure instead of the 404 or 502 that actually happened.
  • Vary: Origin gets forgotten. When the response depends on the request origin and you do not say so, any shared cache or CDN in front of you will happily serve one origin's response to another. This is a cache-poisoning bug, and it is the most common one in the wild.

This module does all four correctly, as a header filter plus a preaccess-phase handler, with no if blocks.

Synopsis

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;
}

A preflight then looks like this, and never reaches 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

Directives

cors

Syntax: cors on | off;

Default: cors off;

Context: http, server, location, if in location

Enables CORS processing. Every other directive is inherited independently, so a nested location that sets one of them does not lose the rest — which is the whole difference from add_header.

cors_origin

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

Default: cors_origin *;

Context: http, server, location, if in location

Which origins may read the resource. Two reserved words and three spec forms:

Value Meaning
* Emit a literal Access-Control-Allow-Origin: *. The same for everyone, so no Vary: Origin is added. Cannot be combined with cors_credentials on.
any Reflect whatever Origin arrives. Credential-safe, emits Vary: Origin.
https://app.example.com Exact match, case-insensitive.
https://*.example.com Wildcard subdomain.
~^https://(a\|b)\.example\.com$ Regular expression. ~* for case-insensitive.

Several specs may be listed. When one matches, that origin is echoed back. * and any cannot be mixed with other values.

Wildcards are deliberately strict: the * must come straight after :// and must be followed by a .. https://*example.com is rejected at startup rather than silently matching https://evilexample.com. A wildcard matches any number of leading labels (https://a.b.example.com matches https://*.example.com), but does not match the bare apex and does not ignore a port — an origin with a port needs an exact entry or a regular expression.

Origin: null — what a sandboxed iframe, a data: document or a file:// page sends — is only ever matched by an explicit null entry in the list when cors_credentials on. Neither any nor a regular expression will match it in that case. Reflecting null with credentials would hand every sandboxed frame on the internet an authenticated read of the response.

cors_methods

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

Default: cors_methods GET HEAD POST OPTIONS;

Context: http, server, location, if in location

The Access-Control-Allow-Methods value sent on a preflight. * cannot be combined with cors_credentials on.

cors_headers

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

Default: — (the header is omitted)

Context: http, server, location, if in location

The Access-Control-Allow-Headers value sent on a preflight. any echoes the request's Access-Control-Request-Headers back verbatim; the header is omitted when the request did not ask for any. * cannot be combined with cors_credentials on.

cors_expose_headers

Syntax: cors_expose_headers <name> ...;

Default: — (the header is omitted)

Context: http, server, location, if in location

Response headers the browser should make readable to script, beyond the safelisted set. Sent on actual responses, not on preflights.

cors_credentials

Syntax: cors_credentials on | off;

Default: cors_credentials off;

Context: http, server, location, if in location

Emits Access-Control-Allow-Credentials: true, allowing cookies and HTTP authentication on cross-origin requests.

The CORS specification forbids pairing credentials with a wildcard, and every browser enforces it — so a config that does both is a config whose CORS never works. NGINX refuses to start rather than let you ship it:

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

Use an explicit list, or cors_origin any to reflect. Note that any plus credentials lets any website read authenticated responses from that location; it is allowed, and it logs a warning at startup.

cors_max_age

Syntax: cors_max_age <time>;

Default: — (the header is omitted)

Context: http, server, location, if in location

How long a browser may cache the preflight result. cors_max_age 0; is a meaningful value and is emitted; omitting the directive omits the header.

cors_preflight

Syntax: cors_preflight on | off;

Default: cors_preflight on;

Context: http, server, location, if in location

Whether to answer matching preflights internally. Turn it off when your application implements OPTIONS itself and you only want the response headers added.

A preflight is short-circuited only when it is genuinely one — an OPTIONS carrying both Origin and Access-Control-Request-Methodand the origin matches. Everything else falls through untouched, so WebDAV and application-level OPTIONS keep working, and a preflight from a disallowed origin simply receives no Access-Control-Allow-Origin, which is what makes the browser reject it.

Because the handler runs in the preaccess phase, a preflight is answered before auth_basic, auth_request and deny get a chance to reject it. That is deliberate: browsers never send credentials on a preflight, so any authentication in front of it would break CORS entirely. Actual requests to the same location are still authenticated normally.

cors_vary

Syntax: cors_vary on | off;

Default: cors_vary on;

Context: http, server, location, if in location

Whether to emit Vary: Origin when the response depends on the request origin.

Leave this on. It is emitted whenever the policy is origin-dependent — including when the origin did not match, and when the request carried no Origin at all, because a cached copy of that response must never be replayed to a request whose origin would have produced different headers. It is deliberately not emitted for a static cors_origin *, which is the same for everybody and does not vary.

Turn it off only if you know no shared cache sits in front of this location, or your CDN already keys on Origin.

Notes

  • A Vary already on the response is extended, never replaced: an upstream sending Vary: Accept-Language comes out as Vary: Accept-Language, Origin, and several upstream Vary lines are folded into one. A Vary: * is left alone, since per RFC 9110 it already subsumes everything.
  • Two things add their Vary after this module and so arrive as a separate field line rather than being folded in: an add_header Vary ... in the same location, and gzip_vary on's Vary: Accept-Encoding. Several Vary field lines mean exactly the same thing as one combined line (RFC 9110 §5.3) and every cache handles it, so this is correct — just not tidy. Load nginx-module-compression-vary if you want everything folded into one line.
  • Do not also set CORS headers with add_header in the same location. This module replaces any Access-Control-Allow-Origin it finds — two of them is a hard failure in every browser — but the other headers would end up duplicated.
  • Headers are emitted on 304 and 206 responses as well as on errors.
  • Subrequests are skipped, matching add_header's behaviour.