CORS preflight fails after adding an Authorization header behind nginx
Moving our SPA from cookie auth to bearer tokens broke every API call with a CORS error. The Authorization header triggered an OPTIONS preflight, our nginx auth check rejected it, and error responses carried no CORS headers.
We were moving our customer portal at app.example.com from session cookies to short-lived JWTs
sent in an Authorization: Bearer header to api.example.com. On staging, the first deploy turned
every API call into a red line in the console. Nothing reached our application logs.
Chrome reported:
Access to fetch at 'https://api.example.com/v2/orders?page=1' from origin 'https://app.example.com'
has been blocked by CORS policy: Response to preflight request doesn't pass access control check:
It does not have HTTP ok status.
Firefox was more explicit about the status code:
Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at
https://api.example.com/v2/orders?page=1. (Reason: CORS preflight response did not succeed).
Status code: 401.
The same endpoints had worked for two years with cookies. The only change on the client was the new header.
Reproducing it
A preflight is easy to reproduce with curl by sending what the browser sends:
curl -i -X OPTIONS https://api.example.com/v2/orders \
-H 'Origin: https://app.example.com' \
-H 'Access-Control-Request-Method: GET' \
-H 'Access-Control-Request-Headers: authorization'
HTTP/2 401
server: nginx
content-type: text/html
content-length: 172
A 401 with no Access-Control-Allow-* headers at all.
What we ruled out
- Missing allowed origin. The API already returned
Access-Control-Allow-Originforhttps://app.example.comon normal requests; we could see it on a plainGETwith curl. - A CDN stripping headers. Requests sent directly to the nginx origin by IP, with the right
Hostheader, failed the same way. - The token itself. A curl
GETwith the same bearer token and anOriginheader returned200with data. The token was valid; the browser just never got to send it.
Root cause
Two problems stacked on top of each other. First, our nginx location /v2/ block used
auth_request to validate the token with an internal auth service for every request, including
OPTIONS. Browsers never send credentials or the Authorization header on a preflight, so the auth
subrequest failed and nginx returned 401. Second, our CORS add_header lines did not use the
always parameter, so nginx only attached them to successful responses. The 401 carried no CORS
headers, and the browser reported a generic CORS error instead of the real status.
What is happening under the hood
The browser sends a preflight OPTIONS request before a cross-origin request that is not a "simple
request". Adding any header outside the CORS-safelisted set, and Authorization is outside it, makes
the request non-simple. With cookies we only sent safelisted headers, so the browser went straight to
the real request and the preflight never existed.
The preflight carries Origin, Access-Control-Request-Method and Access-Control-Request-Headers,
and no credentials. The server must answer with a 2xx status and headers that allow the method and the
listed headers for that origin. If it does not, the browser never sends the actual request.
The nginx detail that hid the real error is documented but easy to miss: add_header applies only to
responses with status 200, 201, 204, 206, 301, 302, 303, 304, 307 or 308 unless always is
specified. On a 401 or 500, CORS headers silently disappear. There is a second trap: add_header
directives are inherited from the enclosing level only if the current level defines none. One
add_header inside a location drops every header set at the server level.
Environment
| Component | Version / value |
|---|---|
| OS | Ubuntu 24.04 LTS |
| Proxy | nginx 1.26.2 with ngx_http_auth_request_module |
| API | Go 1.23 service behind nginx |
| Auth | Internal token introspection service via auth_request |
| Frontend | React SPA on https://app.example.com |
| Browsers tested | Chrome 132, Firefox 135, Safari 18.3 |
Minimal reproduction
server {
listen 443 ssl;
server_name api.example.com;
add_header Access-Control-Allow-Origin https://app.example.com;
location /v2/ {
auth_request /_auth; # runs for OPTIONS too
add_header Access-Control-Allow-Credentials true; # drops the server-level header
proxy_pass http://api_backend;
}
location = /_auth {
internal;
proxy_pass http://auth_service/introspect;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
}
}
Who is affected
Any API that adds custom request headers (Authorization, X-Request-Id, Content-Type: application/json) for a browser on another origin, where an auth layer, WAF rule or framework
middleware runs before the CORS handler and rejects OPTIONS. It is not specific to nginx: the same
ordering mistake happens with Express middleware, Spring Security filters and API gateway authorizers.
The fix
We moved CORS handling into one place at the edge, answered preflights before authentication, and made every CORS header present on every status code.
map $http_origin $cors_origin {
default "";
"https://app.example.com" $http_origin;
"https://admin.example.com" $http_origin;
}
server {
listen 443 ssl;
server_name api.example.com;
location /v2/ {
# 1. Preflight: answer immediately, no auth, no upstream.
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE" always;
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Request-Id" always;
add_header Access-Control-Max-Age 600 always;
add_header Vary Origin always;
return 204;
}
# 2. Real request: authenticate, and attach CORS headers to every status.
auth_request /_auth;
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Expose-Headers "X-Request-Id, Retry-After" always;
add_header Vary Origin always;
proxy_pass http://api_backend;
}
location = /_auth {
internal;
proxy_pass http://auth_service/introspect;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-URI $request_uri;
}
}
A few details matter here:
ifwithreturnis safe. The nginx project warns about complex logic inifinsidelocation, butreturnandrewrite ... lastare the documented safe uses.returnruns in the rewrite phase, which comes before the access phase whereauth_requestruns, so preflights never reach the auth service. Headers declared inside theifblock apply to that response only.- The
mapis the allowlist. Unknown origins get an empty value, and nginx omits a header with an empty value, so foreign sites receive noAccess-Control-Allow-Originat all. Vary: Origin. The response differs by origin, so shared caches must key on it.- We dropped
Access-Control-Allow-Credentials. With bearer tokens we no longer send cookies cross-origin. If you do need credentials,Access-Control-Allow-Originmust be the exact origin, never*, andAllow-HeadersandAllow-Methodscannot be*either.
We also removed the CORS middleware from the Go service so there was exactly one source of truth.
Two layers setting Access-Control-Allow-Origin produce a duplicated header, which the browser
rejects with "contains multiple values".
Alternatives and trade-offs
- Serve the API from the same origin under
app.example.com/apithrough a reverse proxy. No CORS at all, and no preflight latency. It was not an option for us because partner apps also call the API. - Handle CORS in the application instead of nginx. Fine if the framework guarantees CORS runs before auth and on error paths. We preferred the edge so the auth service never sees preflights.
- A larger
Access-Control-Max-Age. Fewer preflights, but browsers cap it (Chromium at two hours) and a long value delays rolling out header changes. 600 seconds was enough to remove most preflights from our latency graphs.
How to test the fix
nginx -t && systemctl reload nginx
# Preflight from an allowed origin: expect 204 and allow headers
curl -si -X OPTIONS https://api.example.com/v2/orders \
-H 'Origin: https://app.example.com' \
-H 'Access-Control-Request-Method: GET' \
-H 'Access-Control-Request-Headers: authorization' | grep -iE '^(HTTP|access-control|vary)'
# Real request without a token: expect 401 WITH the allow-origin header
curl -si https://api.example.com/v2/orders -H 'Origin: https://app.example.com' \
| grep -iE '^(HTTP|access-control)'
# Unknown origin: expect no allow-origin header (prints 0)
curl -si -X OPTIONS https://api.example.com/v2/orders -H 'Origin: https://evil.example.net' \
-H 'Access-Control-Request-Method: GET' | grep -ic '^access-control-allow-origin'
Expected output for the first command:
HTTP/2 204
access-control-allow-origin: https://app.example.com
access-control-allow-methods: GET, POST, PUT, PATCH, DELETE
access-control-allow-headers: Authorization, Content-Type, X-Request-Id
access-control-max-age: 600
vary: Origin
Confirming in production
- The browser console was clean on Chrome, Firefox and Safari, and an expired token now produced a
readable
401in the client, which our refresh logic could handle. Before, it was an opaqueTypeError: Failed to fetch. - The auth service's request rate dropped by about 38 percent, because it no longer received preflights.
- In the RUM data, the p75 time to first API response on page load went from 410 ms to 290 ms once
Max-Agelet browsers reuse preflight results.
Regression guard
We added the three curl checks above as a post-deploy smoke test in CI, failing the pipeline if the
preflight is not 204, if a 401 lacks Access-Control-Allow-Origin, or if an unknown origin
receives an Access-Control-Allow-Origin header. We also run nginx -t in a container in CI on every config
change.
Security implications
CORS is a browser read permission, not authentication. Answering OPTIONS without auth is safe
because a preflight never returns data and never reaches the backend; the real request is still
authenticated. The risky shortcut is reflecting any Origin back in Access-Control-Allow-Origin,
especially together with Allow-Credentials: true, which lets any website read authenticated
responses. Keep an explicit allowlist, include Vary: Origin, and never allow null as an origin.
Prevention checklist
- Put CORS before authentication, and make sure preflights never hit auth or rate-limit rules that require a token.
- In nginx, use
alwayson CORS headers and avoid splittingadd_headeracross levels. - Keep one CORS implementation per request path, not one at the proxy and one in the app.
- Test preflight, error responses and unknown origins with curl in CI.
- When adding any new request header in the client, add it to
Access-Control-Allow-Headersin the same change.
References
- MDN: Cross-Origin Resource Sharing (CORS) — https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
- Fetch Standard: CORS protocol — https://fetch.spec.whatwg.org/#http-cors-protocol
- nginx:
add_headerdirective — https://nginx.org/en/docs/http/ngx_http_headers_module.html#add_header - nginx:
auth_requestmodule — https://nginx.org/en/docs/http/ngx_http_auth_request_module.html - nginx:
mapmodule — https://nginx.org/en/docs/http/ngx_http_map_module.html
Add a label
Embed this post
<iframe src="https://hibug.me/cors-preflight-403-authorization-header-nginx" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I have spent most of my career on the side of software that pages people at night: payment integrations, web platforms with real traffic, CI/CD pipelines, Kubernetes clusters and the databases underneath them. Somewhere along the way I started keeping a notebook of every bug that cost more than an hour — the exact error, what we ruled out, the real root cause and how we proved the fix. Hibug is where those notes live. Every post follows the same path: Investigate, Explain, Fix, Verify — with the environment spelled out, the fix tested, and the security angle checked.
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.