CrawlCheck

Guides · 2026-09-30 · By · 0 views

CSP rollout: from report-only to enforced without a break

How to introduce a Content-Security-Policy without breaking the page: write it from what loads, ship it report-only with a sink that works, read the reports, then enforce.

Ship a Content-Security-Policy in report-only mode first, with a per-response nonce for inline scripts and a report sink that actually receives violations. Two mistakes make the reporting step useless without any visible error: giving report-to a URL instead of a group name defined in Reporting-Endpoints, and pointing report-uri at the site's own origin. CrawlCheck reports both as CSP_REPORTING_BROKEN. When the report stream goes quiet, switch the header name to enforce, and test signed in as well as signed out.

Share of all scans carrying each finding named aboveCSP_REPORTING_BROKEN0.3%Share of all scans carrying eachfinding named aboveCSP_REPORTING_BROKEN0.3%
Read live from the same counters the dataset page uses, at the moment this page was served. Bars are scaled to the largest value shown, not to 100%.

A Content-Security-Policy is the only response header that can break a page while returning 200. Every other security header either works or is ignored; this one, set too tightly, silently removes the scripts, styles and images a page was built on, and the visitor sees a broken layout with no error. That is why the header has a report-only mode, and why the sensible path is not to write the policy and switch it on, but to write it, watch what it would have blocked, fix those, and only then enforce. This guide describes that path as run on crawlcheck.io itself in September 2026, and the two reporting mistakes the scanner checks for on every site.

Two headers, one grammar #

Content-Security-Policy enforces. Content-Security-Policy-Report-Only evaluates the same policy, blocks nothing, and sends a report for every violation it would have blocked. The two can be sent together, with different policies, and browsers keep them separate. The grammar is directives separated by semicolons, each naming a resource class and the sources allowed for it: script-src, style-src, img-src, connect-src, frame-ancestors and so on, with default-src as the fallback for any class not named. A source can be a host, a scheme, the keyword 'self', a hash of the exact inline content, or a nonce.

Step one: write the policy from what the page loads #

Do not start from a template. Start from the network panel of the page, or from the report the scanner produces of which third-party hosts a page actually pulls from. Every host that serves a script, a style, a font, an image, a frame or an XHR target needs a directive that allows it. The inline scripts and styles are the hard part: a policy that permits 'unsafe-inline' for scripts permits every injected script too, which removes most of the protection. The alternative is a nonce, a random value generated per response, placed on the header and on every inline <script> the server writes. A script without the nonce does not run.

Step two: ship it as report-only with a working report sink #

This is the step most sites get wrong, and the scanner has a finding for it. A policy's report-uri directive takes a URL that will receive a POST per violation. The newer report-to directive takes the name of a reporting group, which must be defined in a separate Reporting-Endpoints or Report-To header. Give report-to a URL and the directive is invalid and ignored: the browser evaluates the policy and throws every report away. The scanner raises CSP_REPORTING_BROKEN (0.3% of scans) for exactly that, and for one more case: a report-uri pointing back at the site's own origin, which makes every violation an extra request to the page being reported on. Neither mistake shows up in a browser; the page loads, and the reports never arrive.

DirectiveTakesGiven a URLGiven a name
report-urione or more URLsworks (deprecated, still honoured)invalid
report-toa group name from Reporting-Endpointsinvalid — ignored silentlyworks
Reporting-Endpoints headername="url" pairs—defines the group

Step three: read the reports, not the theory #

On crawlcheck.io the report-only policy went up on 24 September with a per-response nonce and a report endpoint at /api/csp-report that aggregated violations by directive and blocked host. Over the following days the aggregate showed one real violation class, a script bridge loaded from a third-party host, and nothing else. That is the outcome to wait for: a report stream that has gone quiet except for things you recognise. The bridge was self-hosted so it could carry the nonce, the aggregate went to zero, and the policy was switched to enforced with 'strict-dynamic' so that scripts the nonced scripts load are trusted transitively. The whole change sequence is on the trust center and in the security page.

Step four: enforce, and keep report-only beside it #

Switching the header name from Content-Security-Policy-Report-Only to Content-Security-Policy is the enforcement. It is worth keeping a second, report-only header with a tighter draft policy beside the enforced one, so the next tightening can be measured the same way. Browsers apply both: the enforced one blocks, the report-only one reports. Do not send the same policy under both names; that only doubles the report volume.

The bug that only affected signed-in visitors #

A nonce policy has a failure mode that is invisible to a scanner and to most testing: the nonce must be swapped into every inline script on every response, and if any response path skips the swap, the header names a nonce the page does not carry and every inline script on that page is blocked. On crawlcheck.io the swap was applied after the response passed through an asynchronous function for signed-in sessions, and that function was called without being awaited for one deploy. Anonymous responses were fine; signed-in responses carried a header naming a nonce that had never been written into the HTML. The report is in its own post. The lesson for a rollout is to test the enforced policy signed in and signed out, on a page that uses an inline script, before believing the report stream.

How to check a site from outside #

Fetch the homepage headers and read both CSP header names. If report-to appears, confirm its value is a bare token and that a Reporting-Endpoints header defines that token. If report-uri appears, confirm the host is not the site itself. Then load the page in a browser with the console open: an enforced policy that is too tight prints one console line per blocked resource, naming the directive. A scan on crawlcheck.io reports the reporting defects and lists the third-party hosts the page loads, which is the input the policy has to allow. What it cannot tell you is whether the policy is right; only the report stream, read for a few days, can.

Every figure above came out of this scanner.

Point it at your own domain and see the same measurements, free.

Scan a domain — free

The main product

Found this on your own site? We fix it for $749.

Scan free to see where you stand. The fix is one site, every finding implemented and re-measured, with a sealed before and after.

Questions this post answers

What is the difference between Content-Security-Policy and Content-Security-Policy-Report-Only?

Both evaluate the same policy grammar. The enforced header blocks resources that violate it; the report-only header blocks nothing and sends a report per violation. Sending both, with different policies, is allowed and browsers keep them separate.

Why is report-to with a URL a problem?

The report-to directive takes the name of a reporting group defined in a Reporting-Endpoints or Report-To header. Given a URL it is invalid and the browser ignores it, so the policy is evaluated and every report is discarded. The page still loads, so nothing looks wrong.

Is report-uri deprecated?

It is marked deprecated in favour of report-to, but browsers still honour it. A site can send both; the scanner only flags a report-uri that points at the site's own origin, because every violation then becomes an extra request to the page.

What does 'strict-dynamic' do?

It tells the browser to trust any script loaded by a script that already carries a valid nonce or hash, and to ignore host allowlists for scripts. It makes a nonce policy workable for pages whose scripts load further scripts.

How long should a policy stay in report-only?

Until the report stream goes quiet except for violations you recognise. On crawlcheck.io that took a few days of aggregated reports and one self-hosted script; the length depends on how many third-party resources the page loads.

Can a scanner tell me whether my CSP is correct?

It can tell you whether the reporting directives are valid and which third-party hosts the page loads, which is the input the policy must allow. Whether the policy blocks something the page needs only shows in a browser or in the violation reports.

Related findings

How anything measured in this article was measured15client identitiesone second, one address5machine filesapex and www114named agentsresolved from robots.txt24sections scoredreach, read, quoteHow anything measured here was measured15 client identities5 machine files114 named agents24 sections scoredone second, one addressapex and wwwresolved from robots.txtreach, read, quote
No account, nothing installed, and the same sequence on every domain — which is what makes one scan comparable to another. Run it on your own site.

Comments

Comments are read before they appear. Nothing is published automatically, and no account is needed.

Writing about this? Facts, live figures and marks — every number on that page is dated and traceable to a scan.

All guides · The dataset · How the dataset works