The rule most people miss
A nonce-based Content Security Policy is usually explained as a way to allow specific inline scripts: put a per-request random value in the policy, put the same value on the script tags you trust, and inline code injected by an attacker cannot guess it. That description is correct as far as it goes, and it leaves out the part that bites.
Under CSP Level 3, when a script element carries a nonce that matches the policy, the browser stops consulting the host-source expressions for that element. The nonce is not an additional condition on top of the allowlist. It is an alternative to it. A script tag with a valid nonce loads from wherever its src points, whether or not the origin appears in your policy.
That behaviour is intentional and is the foundation of the strict-dynamic pattern, where you deliberately stop maintaining a host allowlist and let trust propagate from nonced scripts instead. The failure mode appears when you believe you are running a host allowlist and a nonce policy at the same time, and something stamps nonces onto elements that were supposed to be judged by the allowlist.
How we did it to ourselves
Our Worker injects a nonce into HTML as it streams, using an HTMLRewriter element handler. The handler was three lines long and did exactly what its name said: for every <script> and <style> element, set the nonce attribute.
Our policy was, and still is, restrictive:
script-src 'self' 'nonce-<value>'
Read together, those two facts mean the allowlist had no effect. Every script tag present in the HTML received a valid nonce, so no script tag was ever evaluated against 'self'. The policy looked strict in the response headers and behaved like script-src * for anything already in the markup.
The proof was sitting on our own authenticated pages. Our console loaded a charting library from a public CDN. That CDN was not in the policy. It loaded anyway, on every page view, and had done since the day the nonce injector shipped. An audit flagged the injector; the CDN tag was the evidence that the flag was real rather than theoretical.
The fix, and the fix that has to ship with it
The correction is a single condition: only stamp elements that have no src.
Inline scripts still need the nonce — that is the entire point of a nonce policy. External scripts should be judged by the allowlist, because that is what the allowlist is for. One line, and script-src becomes a rule again.
There is a catch that makes this a two-part change. The moment the injector stops stamping external scripts, anything loading from an origin outside the policy stops working. In our case that was the charting library, on the console and the admin panel — both authenticated, both rendering customer data. Shipping the CSP fix alone would have replaced a security hole with a visible outage.
So the same change self-hosted the library: pull it from the package registry rather than a CDN, pin it by version and SHA-256, serve it from our own origin, verify the hash on every build. That removes the third-party origin from the page entirely, which is a better outcome than adding the CDN to the allowlist would have been. Third-party executable code on an authenticated page is worth eliminating rather than permitting, particularly when the alternative costs 200 KB of static asset.
How to check your own site in five minutes
This does not need tooling. Load a page and look at the HTML the server actually returned.
- Read the
Content-Security-Policyresponse header. Note whetherscript-srccontains a nonce expression and host expressions. If it contains only a nonce, you are running a nonce policy on purpose and the rest of this does not apply. - Search the response body for script tags carrying both
srcandnonce. Every hit is an element your host allowlist is not evaluating. - For each hit, ask whether its origin appears in the policy. Any that do not are loading purely on the nonce, which means your allowlist has a hole exactly the shape of that tag.
- Search for
integrity=across your pages. If the count is zero and you load third-party scripts, you have no fallback if that origin serves something unexpected.
When we ran step two after deploying the fix, our own pages returned exactly one remaining hit: the analytics beacon our CDN provider injects after our Worker has finished, using our nonce. It carries an integrity hash, so we have left it, documented it, and know precisely why it is there. That is the difference between a policy with an understood exception and a policy that quietly does nothing.
Why this class of bug survives review
Nothing about the broken version looks wrong. The header is strict. The nonce is cryptographically sound and regenerated per request. The helper is small, well named, and does what its comment says. Review catches missing controls far more easily than it catches controls that are present and inert.
The only reliable defence is to verify the control’s effect rather than its existence: not “is there a CSP?” but “what does this policy actually refuse?” The test is cheap, and it is the same question worth asking of every security control you believe you have.
Frequently Asked Questions
Does a nonce override the CSP host allowlist?
Yes. Under CSP Level 3, a script element with a nonce matching the policy is allowed without consulting host-source expressions such as self or a CDN domain. That is deliberate and underpins strict-dynamic, but it means stamping nonces onto external scripts silently disables the allowlist for them.
Should inline scripts still get a nonce?
Yes. Inline scripts have no origin for the allowlist to evaluate, so the nonce is exactly the right mechanism. The distinction is simple: no src means the nonce decides, and a src means the allowlist should decide.
Is Subresource Integrity a substitute for CSP?
No, they answer different questions. CSP controls which origins may execute; SRI verifies that the bytes you received match what you expected. For any third-party script you keep, use both. Better still, self-host it so the third-party origin leaves the page.
How do I test whether my CSP actually blocks anything?
Read the response header, then inspect the served HTML for script tags carrying both src and nonce, and check whether their origins appear in the policy. Any script loading from an origin outside the policy is loading on the nonce alone.
What about scripts injected by my CDN or analytics provider?
Some providers inject tags after your application has produced the response and reuse your nonce. Treat those as documented exceptions: confirm the tag carries an integrity hash, know why it is there, and decide deliberately whether to allowlist the origin or disable the injection.