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.

Reading the stamp trail across four hops A response moves left to right from the application to the browser. The first row of chips shows the allow-origin grant present at the application and sidecar and gone from Nginx onward, while the second row shows every layer's stamp still accumulating, which identifies Nginx as the hop that removed the grant. The response travels left to right, back towards the page that asked for it Application writes the grant Mesh sidecar passes it through Nginx rewrites headers CDN edge caches and serves Browser blocks the read grant present grant present grant gone grant gone read blocked stamps so far app stamps so far app, mesh stamps so far app, mesh, nginx stamps so far app, mesh, nginx stamps observed app, mesh, nginx Nginx stamped the response, yet the grant is gone from there on the header died at that hop, and every layer downstream is innocent A stamp that survives proves the hop ran; a grant that vanishes at the same hop proves what it did

Prerequisite State

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:

Two different responses that both lack the grant The left panel shows a response carrying application and mesh stamps but no access-control-allow-origin, meaning a later hop removed it. The right panel shows a response with no stamps at all, meaning the request never reached the instrumented layers. Removed in transit x-cors-stamp: app x-cors-stamp: mesh vary: origin cache-control: no-store no access-control-allow-origin line The CORS code ran and stamped the response, so a hop after the last stamp deleted the grant. Never written server: nginx vary: accept-encoding content-type: text/html x-request-id: 8ac1f4 no stamp headers at all Nothing you instrumented touched this response: an intermediary generated it by itself. The surviving stamps separate a header that was deleted from one that was never written

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:

Bisection ladder from the innermost listener outwards Five probe points listed innermost first, each with the command used to reach it and whether the allow-origin grant was present. The grant is present at the application and sidecar and absent from the Nginx listener outwards, which names Nginx as the hop that removed it. Probe point, innermost first How you reach it Grant in the response Application listener 127.0.0.1:8080 curl on the host present Mesh sidecar port 15001 curl inside the pod present Nginx TLS listener on the node curl with --resolve absent — first miss Load balancer hostname curl the LB name absent Public edge hostname browser and curl absent The first probe that comes back without the grant names the hop that removed it

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.