Guides · 2026-09-30 · By VSNARY | Emmanuel Orta · 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.
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.
| Directive | Takes | Given a URL | Given a name |
|---|---|---|---|
report-uri | one or more URLs | works (deprecated, still honoured) | invalid |
report-to | a group name from Reporting-Endpoints | invalid — ignored silently | works |
Reporting-Endpoints header | name="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.
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
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.