Web Development Bug Report

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.

Daniel Tran Daniel Tran @fun_dev · February 11, 2025 · 9 min read · 19 Views
Banner: CORS preflight request rejected by nginx after adding an Authorization header

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-Origin for https://app.example.com on normal requests; we could see it on a plain GET with curl.
  • A CDN stripping headers. Requests sent directly to the nginx origin by IP, with the right Host header, failed the same way.
  • The token itself. A curl GET with the same bearer token and an Origin header returned 200 with 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:

  • if with return is safe. The nginx project warns about complex logic in if inside location, but return and rewrite ... last are the documented safe uses. return runs in the rewrite phase, which comes before the access phase where auth_request runs, so preflights never reach the auth service. Headers declared inside the if block apply to that response only.
  • The map is the allowlist. Unknown origins get an empty value, and nginx omits a header with an empty value, so foreign sites receive no Access-Control-Allow-Origin at 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-Origin must be the exact origin, never *, and Allow-Headers and Allow-Methods cannot 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/api through 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 401 in the client, which our refresh logic could handle. Before, it was an opaque TypeError: 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-Age let 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 always on CORS headers and avoid splitting add_header across 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-Headers in the same change.

References

Add a label

#cors #nginx #preflight #http #jwt
Written by
Daniel Tran @fun_dev

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.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.