Tutorial
Cloudflare Workers _headers: Why Your Rules Aren't Applying
· 7 min read
On this page
This site ships its Content Security Policy through a _headers file. Not a Worker script, not
a Transform Rule — a plain text file that Cloudflare reads at deploy time and applies to static
asset responses. It has worked without incident since I set it up, which is exactly why I never looked
closely at how it resolves rules.
Then I went looking for how people break it. Cloudflare Community threads about _headers doing
nothing on Workers static assets, and workers-sdk issue
11351 about a route ending up with
two CSP headers, describe the same class of failure: the file parses, the deploy succeeds, and
the headers still aren’t what you wrote. No error tells you why.
So I measured it. Everything below is from curl against a local wrangler dev (4.119.0) and
against production, with the rules deliberately put in conflict. The short version: matching
rules stack, they don’t override — there’s a specific syntax for overriding that most people
miss, and one mistake that deletes a rule silently.
Where the file goes
Same place as the _redirects file: the root of your
build output. For Astro that means public/_headers, which gets copied verbatim into dist/.
The syntax is a path pattern on one line, then indented Name: value pairs:
/*
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
/_astro/*
Cache-Control: public, max-age=31536000, immutable
The file itself is never served. A request for /_headers returns 404 in both wrangler dev
and production — I checked, because a policy file that leaks its own contents would be a bad
default.
One wrinkle worth naming: on this site the file isn’t in public/ at all. The CSP is
hash-based, so the header changes whenever an inline script changes, and a hand-maintained file
would drift out of sync on the first edit. Instead an Astro integration writes dist/_headers
in the astro:build:done hook after scanning the built HTML for inline scripts. The CSP
post covers why hashes rather than nonces. For the
purposes of this article, generated or hand-written makes no difference — Cloudflare sees the
same file.
Rules stack. They do not override.
This is the behavior that produces the duplicate-CSP bug, and it’s the opposite of what CSS
specificity or _redirects first-match semantics train you to expect.
I put two rules in conflict, both matching /blog/:
/*
X-Test: from-star
Cache-Control: public, max-age=60
/blog/*
X-Test: from-blog
Cache-Control: public, max-age=120
The more specific rule does not win. Neither does the first one. Both apply:
x-test: from-star
x-test: from-blog
Cache-Control: public, max-age=60, public, max-age=120
Two X-Test headers on the response. And Cache-Control — a header whose value is a
comma-separated list — gets the two values joined into one nonsensical string.
Now substitute Content-Security-Policy for X-Test and the reported bug explains itself. When
a browser receives two CSP headers it enforces both, and the effective policy is their
intersection: a resource must be allowed by every policy present. A second CSP that’s more
permissive doesn’t loosen anything. A second CSP that omits a hash your inline script needs
blocks that script, no matter how correct your first policy is. The console message points at
the policy, so you go read the policy, and the policy looks fine — because the problem is that
there are two of them.
Reversing the order of the two rules changes which value comes first and nothing else. There’s no precedence to exploit here — overriding takes an explicit instruction, which is the next section.
Repeating a pattern deletes the earlier rule
This one is worse, because it fails silently in the other direction.
/blog/*
X-A: first-block
/blog/*
X-B: second-block
Response:
x-b: second-block
X-A is gone. Wrangler’s startup log says ✨ Parsed 2 valid header rules. — both blocks are
valid, both were parsed, and one of them was then discarded. Rules are keyed by pattern, so a
second block with the same pattern replaces the first wholesale rather than merging into it.
This is easy to do by accident in a generated file, or in a hand-written one that’s grown past a
screenful. It’s also the interaction that made my first measurement of the stacking behavior
wrong: I’d left a duplicate /blog/* block in the test file, which silently ate the rule I was
trying to observe and made stacking look like first-rule-wins. Worth knowing before you debug
something else on top of it.
The fix is mechanical — one block per pattern, headers merged by hand:
/blog/*
X-A: first-block
X-B: second-block
! is how you actually override
Prefixing a header name with ! removes it. That’s the documented way to strip a Cloudflare
default:
/*
! Cache-Control
X-Keep: yes
The response comes back with x-keep: yes and no Cache-Control at all. Note the space after
the !.
The part that isn’t obvious is that ! also clears a value set by another rule in the same
file, and you can set a new value on the very next line. That combination is the override the
stacking behavior otherwise denies you:
/*
X-Test: from-star
/blog/*
! X-Test
X-Test: from-blog
x-test: from-blog
One header, the narrow rule’s value. This is the shape to reach for whenever a broad rule sets a site-wide policy and one path needs a different one — unset, then set.
Worth knowing that this hasn’t always worked: unset-and-immediately-reset was a feature request that only closed in February 2026. Guidance written before then tells you to restructure your rules instead, which is no longer necessary.
The caveat is issue 11351 above. The reporter found that on the root route / specifically, the
unset is ignored and both CSP headers survive — Cloudflare confirmed it as a bug in November
2025 and it’s still open. I could not reproduce it locally on wrangler 4.119.0; / returned the
overridden value like any other path. Local wrangler dev and the production edge are separate
implementations, though, so if you’re relying on an unset at /, curl -I the deployed URL
rather than trusting the dev server.
Against Cloudflare’s own defaults, no ! is needed — _headers wins outright. Static asset
responses ship with Cache-Control: public, max-age=0, must-revalidate, and on this site
/_astro/* replaces it with a year:
$ curl -sSI https://astro.p4ni.com/_astro/page.CQWjsXKf.js
cache-control: public, max-age=31536000, immutable
One value, not a comma-joined merge with the default. Everything under /_astro/ carries a
content hash in its filename so a URL can never serve stale bytes; HTML keeps the platform
default, which is what a site that redeploys daily wants.
What _headers cannot do
It doesn’t touch Worker responses. The rules apply to static asset responses only. If you
have a Worker script in front of your assets and it generates a response — SSR, an API route,
anything returned from your own code — your _headers rules are not applied to it. The docs
carry this as a caution and it’s the single most common reason the file appears to do nothing:
the request is being served by code, not by the asset system. If you’re running with
run_worker_first, that’s most of your traffic.
Lines are capped at 2,000 characters, and rules at 100. The line cap is the one to watch if you’re serving a CSP. Mine is currently 806 characters with five inline script hashes in it. A SHA-256 hash entry costs about 52 characters, so there’s room for roughly twenty more before the policy hits the ceiling — comfortable, but not unbounded, and a site that inlines a script per component would get there. Truncation at 2,000 characters would cut a policy mid-directive.
Measuring it locally
wrangler dev reads the file and applies the rules, so you don’t need a deploy to check your
work:
npx wrangler dev --port 8788
curl -sSI http://localhost:8788/blog/
The startup log confirms the parse — ✨ Parsed 2 valid header rules. — and editing the file
hot-reloads the local server. Every result in this post reproduced identically against
production, which makes this a trustworthy loop.
Two caveats. The count in that log line tells you nothing about correctness: my duplicate-pattern
test reported two valid rules while discarding one of them. And the framework dev server is a
different thing entirely — astro dev serves from Vite and never reads _headers. On this site
the file doesn’t even exist during astro dev, since it’s written at build time. Check headers
against wrangler dev or against the deployed site, never against the framework’s dev server.
The rules that actually matter
If you’re debugging a _headers file that isn’t behaving, work down this list:
- Is a Worker generating the response? If yes, nothing in
_headersapplies. Set the header in your code. - Does more than one matching rule set the same header? They stack. Two CSP headers means
the browser enforces the intersection of both. Add
! Header-Nameto the narrow rule before setting its own value. - Does a pattern appear twice? The later block silently replaces the earlier one.
- Are you testing against the framework dev server? It doesn’t read the file.
The mental model that keeps me out of trouble is that _headers is not a cascade. It’s a set of
matchers, and every one that matches contributes its headers to the response — specificity buys
you nothing on its own. Overriding is an explicit operation with its own syntax, and once you’re
reaching for ! in a narrow rule, you’ve understood the file.