Finding Which Layer Stripped the CORS Header
The application log clearly shows Access-Control-Allow-Origin being written on the very request the browser rejected. Nothing is wrong with the CORS code, yet the response reaching the page has no grant on it at all. This guide turns that contradiction into a mechanical search for the hop that removed the header, using the response headers pane described in Inspecting Preflight in the DevTools Network Panel plus a stamping trick that survives every intermediary.
Failure symptom:
Access to fetch at 'https://api.northwind-cloud.dev/v2/usage' from origin
'https://console.northwind-cloud.dev' has been blocked by CORS policy: No
'Access-Control-Allow-Origin' header is present on the requested resource.
Meanwhile the service’s own access log, for the same request id, records access-control-allow-origin=https://console.northwind-cloud.dev on a 200.
Root Cause
A response header is a mutable object right up to the moment it reaches the client. The application writes it, then hands the response to whatever sits in front — a mesh sidecar, a reverse proxy, a load balancer, a CDN — and each of those is allowed to add, replace or delete headers on the way out. Several do so by default: a proxy that regenerates an error page discards the upstream’s headers entirely, a route-level header policy that enumerates permitted response headers drops everything unlisted, and a header named in the Connection field is defined as hop-by-hop and must be removed by the next proxy. None of that is visible from either end of the path, which is why guessing is so expensive.
The reliable move is to make each layer sign its work. If every hop appends a distinct value to a custom header, the set of values that arrives tells you precisely how far the response travelled intact — and the layer whose stamp is present while the CORS grant is absent is the layer that removed it.
Prerequisite State
- You can deploy a one-line change to each layer on the path, or at least reload its configuration.
- You know the full topology: every listener between the application socket and the public hostname.
curlis available on a host inside the network, so you can probe internal listeners directly.- The failure is reproducible on demand — an intermittent strip usually means a cache, which the checklist at the end covers separately.
Step-by-Step Fix
Step 1 — Confirm the header is genuinely absent
Before instrumenting anything, rule out the two ways DevTools misleads you. Open the Network panel, tick Preserve log and Disable cache, and reproduce. Select the failing request and read the Response Headers section. If it is prefixed with Provisional headers are shown, you are not looking at a real response at all — the request was blocked or served from a source that never produced headers, and the search below does not apply. If the section is fully populated and simply has no access-control-allow-origin line, you have a genuine strip.
Note the value of any request-id header on that response. Correlating it with the application log is what proves the two sides disagree, and it is the same correlation the wider workflow in Troubleshooting CORS at the Proxy Layer relies on.
Step 2 — Stamp every layer
Add one header at every hop, appending rather than replacing, so the values accumulate into a trail. Keep the name identical everywhere and the value unique per layer.
In the application, append the stamp in the same middleware that sets the CORS headers, so the two can never diverge:
const LAYER = process.env.CORS_STAMP_LAYER || 'app';
app.use((req, res, next) => {
const origin = req.get('Origin');
if (origin && ALLOWED.has(origin)) {
res.set('Access-Control-Allow-Origin', origin);
res.set('Vary', 'Origin');
}
res.append('X-Cors-Stamp', LAYER);
next();
});
In Nginx, add_header appends by default, and always keeps the stamp on error responses too — without it you lose the stamp exactly when you need it most:
location /v2/ {
proxy_pass http://usage_upstream;
add_header X-Cors-Stamp "nginx" always;
}
In an Envoy-based mesh, attach it to the route so it is applied on the response leg:
response_headers_to_add:
- header:
key: x-cors-stamp
value: mesh
append_action: APPEND_IF_EXISTS_OR_ADD
Deploy all of them, then reproduce once more.
Step 3 — Read the trail in the response headers pane
Back in DevTools, the response now answers two questions at once. The stamps that appear tell you which layers ran; the absent grant tells you it did not survive one of them. The distinction that matters is between a header that was written and then removed, and one that was never written at all:
An empty trail is its own diagnosis and sends you somewhere else entirely: an intermediary produced the response without ever consulting your service, which is the pattern behind Debugging Missing Access-Control-Allow-Origin Header when a firewall or an error page answers first.
Step 4 — Walk the path with curl and find the first absent header
The stamp trail narrows the search to one hop; a bisection with curl confirms it and gives you a reproducible probe to test the fix against. Probe each listener from the innermost outward, sending the same Origin the browser sent.
# Innermost: the application's own listener, no proxy in the path
curl -sS -o /dev/null -D - http://127.0.0.1:8080/v2/usage \
-H 'Origin: https://console.northwind-cloud.dev' \
| grep -iE '^(x-cors-stamp|access-control-allow-origin):'
# One hop out: the Nginx TLS listener on the node, addressed by name
curl -sS -o /dev/null -D - --resolve api.northwind-cloud.dev:443:10.4.11.7 \
https://api.northwind-cloud.dev/v2/usage \
-H 'Origin: https://console.northwind-cloud.dev' \
| grep -iE '^(x-cors-stamp|access-control-allow-origin):'
# Public edge, exactly what the browser reaches
curl -sS -o /dev/null -D - https://api.northwind-cloud.dev/v2/usage \
-H 'Origin: https://console.northwind-cloud.dev' \
| grep -iE '^(x-cors-stamp|access-control-allow-origin):'
Line the answers up and read down the column. The first probe whose response lacks the grant is the layer that removed it, and everything past that point is downstream noise:
Step 5 — Repair the named layer
Each layer removes headers for its own reason, and the fix has to match the mechanism rather than the symptom:
| Layer | Mechanism that removes the header | Repair |
|---|---|---|
| Nginx | An add_header in a nested location discards every inherited add_header |
Re-declare the CORS headers in that block, each with always |
| Nginx | proxy_hide_header on the upstream response |
Remove the directive, or replace it with an explicit add_header |
| Any proxy | The header name appears in the response’s Connection field, making it hop-by-hop |
Never list Access-Control-* in Connection; strip that field upstream |
| Service mesh | A route policy that removes or enumerates permitted response headers | Add the grant to response_headers_to_add on that route |
| API gateway | A response mapping that only passes declared headers | Declare Access-Control-Allow-Origin and Vary in the mapping |
| CDN | A cached copy created before the CORS configuration shipped | Purge the path, then add Vary: Origin so origins never share an entry |
| Error path | The proxy generated the 502 itself, so the application never ran |
Attach CORS headers to the proxy’s own error responses |
The Nginx inheritance rule catches the most people, because the configuration looks correct in isolation — the parent block does set the header, and the child block sets an unrelated one. Adding Vary: Origin back after the repair matters just as much for caches, which is covered in Handling Vary: Origin Header Correctly.
Verification
Re-run the innermost and outermost probes and confirm both now agree:
for target in "http://127.0.0.1:8080" "https://api.northwind-cloud.dev"; do
printf '%-34s -> ' "$target"
curl -sS -o /dev/null -D - "$target/v2/usage" \
-H 'Origin: https://console.northwind-cloud.dev' \
| grep -ic '^access-control-allow-origin:'
done
Both lines must print 1. A 0 on the second line means the layer is still stripping; a 2 means you fixed it by adding a second copy somewhere, which trades this bug for the one described in Fixing Duplicate Access-Control-Allow-Origin Headers.
Security Boundary Note
Stamp headers describe your internal topology — how many hops exist and what each one is called — so they belong in an incident toolkit, not in every public response. Gate them behind an environment variable or a staging hostname and remove them once the fix is verified. Equally important: do not “fix” a stripped header by having the outermost layer add Access-Control-Allow-Origin unconditionally. That converts a visibility problem into a policy problem, because the edge has no idea which origins the application intended to trust, and a reflected or wildcard grant added at the edge applies to every route behind it — including the ones that were deliberately closed.
Common Mistakes
| Mistake | Why it misleads | Better move |
|---|---|---|
| Trusting the application log as proof the browser received the header | The log records what was written, not what survived the path | Compare the log against the response headers pane for the same request id |
| Reading a response marked Provisional headers are shown | Those are request headers the browser guessed; no response exists yet | Reproduce with the cache disabled and read a completed response |
| Adding the grant at the outermost layer to make the error go away | The edge cannot know which origins each route trusts | Repair the hop that removed it, and keep policy in one place |
| Testing only the actual request | A preflight can be stripped independently by a method-specific rule | Probe OPTIONS and the real method separately |
FAQ
Why does my application log show the header when the browser does not?
Because the log records what the application handed to its socket, not what arrived at the client. Everything between the two — a sidecar, a reverse proxy, a load balancer, a CDN — is free to rewrite the response, and several of them remove headers by default rather than by mistake. The application log is evidence that the header was written, and the Network panel is evidence that it did not survive; the whole diagnosis is finding the hop between those two facts.
Does a missing header always mean an intermediary removed it?
No, and that is exactly what the stamp headers rule out. If none of your stamps appear either, nothing you instrumented ever touched the response: an intermediary generated it on its own, typically as a 403 from a firewall rule, a 502 from a failed upstream, or a cached copy created before the CORS configuration was deployed. If the stamps do appear and only the Access-Control headers are missing, a hop that ran after the stamping layer removed them.
Should I leave the stamp headers switched on in production?
Leave them off by default and behind an environment variable you can flip during an incident. They describe your internal topology — how many hops there are and what each one is called — which is information you have no reason to publish. A reasonable compromise is emitting them only when the request carries a known debug header from an operator, or only on a staging hostname, so the technique is available without becoming a permanent part of every public response.