cors: Correct CORS for NGINX, including preflight and Vary
Requires the Pro plan (or higher) of the GetPageSpeed NGINX Extras subscription.
Installation
You can install this module in any RHEL-based distribution, including, but not limited to:
- RedHat Enterprise Linux 7, 8, 9 and 10
- CentOS 7, 8, 9
- AlmaLinux 8, 9
- Rocky Linux 8, 9
- Amazon Linux 2 and 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
Enable the module by adding the following at the top of /etc/nginx/nginx.conf:
load_module modules/ngx_http_cors_module.so;
This document describes nginx-module-cors v1.0.0 released on Aug 15 2026.
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_headeris inherited-or-replaced, per level. The moment anylocationadds a single header of its own, everyadd_headerset above it — including your CORS headers — silently disappears. Nothing warns you.- Preflight needs a short-circuit. An
OPTIONSpreflight has to be answered with a204and the right headers, without reaching your application. Doing that by hand means anifblock, and it interacts badly withtry_filesandproxy_pass. add_headeronly fires on a whitelist of status codes unless you passalways. 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: Origingets 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-Method — and 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
Varyalready on the response is extended, never replaced: an upstream sendingVary: Accept-Languagecomes out asVary: Accept-Language, Origin, and several upstreamVarylines are folded into one. AVary: *is left alone, since per RFC 9110 it already subsumes everything. - Two things add their
Varyafter this module and so arrive as a separate field line rather than being folded in: anadd_header Vary ...in the same location, andgzip_vary on'sVary: Accept-Encoding. SeveralVaryfield 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. Loadnginx-module-compression-varyif you want everything folded into one line. - Do not also set CORS headers with
add_headerin the same location. This module replaces anyAccess-Control-Allow-Originit 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.