GuidesWEB SERVERS
Fix CORS errors in nginx
The browser blocks script access to HTTP responses that lack matching Access-Control headers, so the fix is to make the web server emit them. Resolving an nginx CORS failure means configuring nginx to return these headers on API responses, preflights, and error pages alike. This guide applies when you operate the API behind nginx; if the API belongs to a third party, no configuration on your side can fix it.
Confirm nginx is the right layer
A console CORS error means the browser withheld the response from client script, not that nginx crashed or rejected the connection. Confirm the failure is actually an origin mismatch and that the API runs behind an nginx instance you control. If you are uncertain whether the error originates from network timeouts or missing headers, step through the diagnosis guide.
If the target API belongs to a third party, no nginx configuration on your origin server can fix the browser check. For public GET and HEAD data on any public HTTPS host, you can prefix the target URL with https://proxy.cors.dev/. The proxy operator can observe traffic, so route public data only.
Add the response headers
The add_header directive belongs inside the location block that serves or proxies your API. It applies identical response headers whether nginx serves static files directly or forwards the request downstream with proxy_pass.
# /etc/nginx/sites-available/api.example.com
location /api/ {
add_header 'Access-Control-Allow-Origin' 'https://app.example.com' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type' always;
# 'always' is required, or error responses (4xx/5xx) lose these headers
proxy_pass http://127.0.0.1:3000;
}Include the always parameter on every add_header directive. Without always, nginx only appends the headers to responses with status codes 200, 201, 204, 206, 301, 302, 303, 304, 307, or 308. When an upstream then returns 401, 404, or 500, the response goes out without the CORS headers and the browser masks the real status behind a CORS failure. Test your syntax with nginx -t and reload with systemctl reload nginx.
Whitelist your real origins
Hardcoding a single origin breaks secondary environments like staging or local development. Using a wildcard * permits arbitrary sites to read responses and is rejected by browsers whenever the request includes credentials.
# http level, for example /etc/nginx/conf.d/cors-map.conf
map $http_origin $cors_origin {
default '';
'https://app.example.com' $http_origin;
'https://staging.example.com' $http_origin;
}
# then, on the API location inside the server block:
location /api/ {
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Vary' 'Origin' always;
# unlisted origins get an empty value, and nginx sends no header
proxy_pass http://127.0.0.1:3000;
}- Setting
default ''in the map makes unlisted origins evaluate to an empty string. nginx does not send a header with an empty value, so those origins receive noAccess-Control-Allow-Originat all and the browser blocks script access. - Adding
add_header 'Vary' 'Origin' always;tells shared caches and CDNs that the response depends on the incomingOriginheader, so one cached allowance is never served to a different origin. - Keep temporary staging and preview origins in the map only while active, and remove them when environments are decommissioned.
Answer the OPTIONS preflight
Requests carrying custom headers, Authorization, or a non-simple content type like application/json trigger a preliminary browser probe using the OPTIONS method. nginx can answer this preflight directly and return an empty 204 response without waking your application server.
location /api/ {
if ($request_method = 'OPTIONS') {
# an if block with its own add_header inherits none from the
# location, so every CORS header is repeated here
add_header 'Access-Control-Allow-Origin' 'https://app.example.com' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type' always;
add_header 'Access-Control-Max-Age' '86400' always;
return 204;
}
add_header 'Access-Control-Allow-Origin' 'https://app.example.com' always;
proxy_pass http://127.0.0.1:3000;
}The catch is the inheritance model: a configuration level that declares any add_header directive of its own inherits none from its parent. The if ($request_method = 'OPTIONS') block contains add_header directives, so it inherits none from the surrounding location, and every CORS header must be repeated inside it. Setting Access-Control-Max-Age 86400 lets the browser cache the preflight verdict, capped by the browser at 600 seconds in WebKit and 7200 seconds in Chromium. For detailed probe mechanics, read the preflight guide.
Cookies, authorization, and the wildcard trap
Browsers strictly reject responses when Access-Control-Allow-Origin is configured as a wildcard * and the request includes credentials such as cookies, TLS client certificates, or ambient authorization tokens.
- The client application must explicitly request credential transmission by specifying
fetch(url, { credentials: 'include' }). - The server configuration must return the requesting origin explicitly rather than a wildcard and must append
add_header 'Access-Control-Allow-Credentials' 'true' always;. - Never dynamically reflect the raw incoming
$http_originheader without validation when credentials are enabled. Blind reflection converts the endpoint into an open session relay, which exposes authenticated user data to unauthorized origins.
Verify with curl
Browsers mask protocol-level details behind generic error text in the console. Testing endpoints directly with curl surfaces raw headers and confirmed status codes without browser filtering.
# Simple GET: expect access-control-allow-origin in the response
curl -i -H "Origin: https://app.example.com" https://api.example.com/api/status
# Preflight: expect 204 with allow-methods and allow-headers
curl -i -X OPTIONS \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: content-type" \
https://api.example.com/api/statusVerify that standard requests return access-control-allow-origin alongside your payload, and confirm that the preflight OPTIONS call returns an HTTP 204 status with access-control-allow-methods and access-control-allow-headers. Next, run a probe against an unauthorized route that returns 401 or 404 to ensure the always parameter maintains CORS headers during backend error states.
Good questions.
Why does a 500 or 401 error trigger a CORS failure in the browser?
nginx only attaches add_header values to success and redirect status codes unless the always parameter is appended. When an upstream failure returns 500 without always, the response goes out without Access-Control-Allow-Origin, and the browser masks the upstream error behind a CORS rejection.
Why did my location headers disappear after adding an OPTIONS block?
nginx inherits add_header directives from parent contexts if and only if the current block declares none of its own. An if block inside a location that contains any add_header directive inherits zero headers from the enclosing location, requiring you to re-declare every header inside that block.
Why do requests succeed in Postman but fail in the browser?
Postman is a direct HTTP client that ignores the same-origin policy entirely. For an explanation of why server tools bypass these constraints, read the Postman comparison.
Can I check my CORS headers without triggering local browser caching?
Yes. Command-line tools like curl and the hosted header checker inspect raw response headers without browser caching or extension interference.