Identify the responsible layer before changing anything
Record required origins, methods and request headers. Establish whether the browser uses credentials: "include". CORS for APIs explains the selection rules.
Then examine the delivered response and your upstream's response separately. If the application already sets a complete CORS rule, the correct nginx configuration may simply pass it through unchanged. Central proxy headers only make sense if they match the route and client model. Browser permissions do not replace upstream authorisation.
Record the nginx version and all effective includes. add_header ... always also applies to response statuses excluded without always. Classic inheritance adopts parent-level add_header directives only if none are defined at the current level. Newer configurations can change this with add_header_inherit; the documentation states that the directive has existed since 1.29.3. See nginx: headers module.
A self-contained local proxy demo
The configuration uses only loopback addresses. A synthetic upstream runs on port 43103, the proxy on 43102. The allowed frontend origin is http://127.0.0.1:43101. No authentication, real cookies, customer data or external destinations are used. The HTTP addresses apply only to this local demonstration; they are not a transport template for a published API.
Save the following content as nginx.conf in your own empty working directory. The rule uses no external configuration includes:
worker_processes 1;
pid logs/nginx.pid;
error_log logs/error.log warn;
events {
worker_connections 64;
}
http {
access_log off;
map $http_origin $cors_origin {
default "";
"http://127.0.0.1:43101" "http://127.0.0.1:43101";
}
map $cors_origin $cors_credentials {
default "";
"http://127.0.0.1:43101" "true";
}
server {
listen 127.0.0.1:43103;
server_name localhost;
default_type application/json;
location = /demo/data {
return 200 '{"message":"synthetic data"}';
}
location = /demo/error {
return 503 '{"message":"synthetic unavailable"}';
}
location / {
return 404;
}
}
server {
listen 127.0.0.1:43102;
server_name localhost;
location /demo/ {
proxy_hide_header Access-Control-Allow-Origin;
proxy_hide_header Access-Control-Allow-Credentials;
proxy_hide_header Access-Control-Allow-Methods;
proxy_hide_header Access-Control-Allow-Headers;
proxy_hide_header Access-Control-Expose-Headers;
proxy_hide_header Access-Control-Max-Age;
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Credentials $cors_credentials always;
add_header Access-Control-Allow-Methods "GET" always;
add_header Access-Control-Allow-Headers "X-Demo" always;
add_header Vary "Origin" always;
if ($request_method = OPTIONS) {
return 204;
}
proxy_pass http://127.0.0.1:43103;
}
location / {
return 404;
}
}
}
The map module maps allowed values to a defined response. An unknown origin receives an empty value here. The nginx header filter does not add an empty add_header value; the official header-filter source code shows the corresponding check in ngx_http_add_header.
The credentials signal demonstrates only CORS mode. It signs in no user. Likewise, Access-Control-Allow-Methods: GET tells the browser what preflight permits; it does not block other methods upstream. The demo changes no data regardless of method. A real application must enforce its own method and permission contract.
Handle duplicate headers and inheritance deliberately
proxy_hide_header suppresses the named upstream response headers. In this example, nginx fully owns the CORS rule and therefore removes the corresponding upstream values. This is a deliberate ownership decision; do not copy this block if the application should actually provide different route rules. The nginx proxy module documents the function.
The upstream's Vary header is not hidden. Other variants named there, such as content or compression, must remain. Origin is added here for dynamic origin permission. The demo configures no response cache; personalised content additionally needs appropriate Cache-Control rules in a real environment.
The CORS headers sit together at the same location level, and the OPTIONS branch adds no different add_header directives. This avoids a typical unintended inheritance change. In an existing configuration, however, merely adding location headers can displace parent headers. Therefore also check other headers required by this route. An isolated excerpt does not explain every rule on the production server.
Verify syntax, headers and browser behaviour separately
The following commands require a locally installed nginx version with the standard modules shown and available demo ports. Syntax, startup and shutdown were tested with nginx 1.30.5. In your demo directory, the relative prefix also places the PID and logs there:
mkdir -p logs
nginx -t -p "$PWD/" -c nginx.conf
nginx -p "$PWD/" -c nginx.conf
Start only after configuration validation succeeds. A local preflight can then be observed as follows:
curl -i -X OPTIONS http://127.0.0.1:43102/demo/data \
-H 'Origin: http://127.0.0.1:43101' \
-H 'Access-Control-Request-Method: GET' \
-H 'Access-Control-Request-Headers: X-Demo'
Expect 204, exactly one matching origin permission, true, GET and X-Demo. Repeat the header-only probe with a test origin you control that is not allowed: no matching origin permission should appear. This is not yet a browser test, because curl does not enforce CORS.
The complete demo was tested sequentially on 27 September 2026 on macOS 26.0.1 in Chromium 147.0.7727.15, Firefox 148.0.2 and Playwright WebKit 26.4. The allowed origin could read statuses 200 and 503 with JSON; buttons on the disallowed origin failed at preflight without a subsequent GET. An additional simple GET without X-Demo was sent, however, while its response remained blocked for JavaScript. A local HTTP client without Origin could read the data. A separate upstream test confirmed exactly one CORS permission and preservation of Vary: Accept-Encoding alongside Origin, including on 503. A custom parent header was displaced by the location headers as described. WebKit here means the Playwright engine, not testing in native Safari or on an iPhone.
Also check GET on /demo/data and /demo/error. For the allowed origin, required headers should also appear on 503. The browser must be able to read both responses from the allowed frontend context; the error remains an error status. From another local origin, JavaScript must not read the responses. For local browser verification, use the complete browser client from the Express example. Its stated Node and package prerequisites still apply. If the complete Express demo process is already running, stop it first with Ctrl+C; then, in the directory containing demo.mjs, start node demo.mjs --ui-only. This opens only the browser interfaces on 43101 and 43104, leaving API port 43102 free for the separately started nginx demo. Open both UI addresses and use the data and error buttons. The optional display field result may be null here because the nginx demo sends no X-Demo-Result.
Roll out your configuration in a controlled way
Adopt the required principles from the demo, not its local ports or synthetic upstream. Before a real change, preserve the effective configuration, validate the candidate and test allowed origins, rejected origins, preflights and error paths in a suitable environment of your own. Account for upstream gateways and internal error redirects.
For the demo, the following command stops only the nginx instance associated with the specified prefix and its PID file:
nginx -p "$PWD/" -c nginx.conf -s quit
For real configuration rollback, load the previously tested secure version through your existing operational process and repeat the affected verification. Close overly broad permission specifically. The API Security Check can observe public headers, but validates neither your nginx includes nor real credential or browser flows. Sources checked: 27 September 2026.
Sources and date
Sources checked: 27 September 2026. Links also appear alongside the statements they support.