Rolling Out CSP in Report-Only Mode
A content security policy is one of the few controls that can break a working site instantly and completely. Ship a strict one straight into enforcement and the third-party analytics tag, the inline handler in a template nobody has touched for four years, and the widget the marketing team added last month all stop working at once — usually on a Friday.
Report-only mode exists for exactly this. It sends the policy, collects violations from real browsers on real traffic, and blocks nothing. This guide runs that rollout end to end: deploying the target policy for reporting, filtering the extension noise that dominates the volume, fixing the application rather than diluting the policy, and switching to enforcement when the reports have gone quiet. It is part of the Secure HTTP Header Configuration guide within Vulnerability Patterns & Web Mitigation Strategies, and the policy it produces is the backstop behind XSS mitigation.
Prerequisites
- The ability to set response headers per environment
- A report collection endpoint, or a service that provides one
- Server-rendered pages able to emit a per-response nonce, or a build that can produce hashes
- Patience for one full traffic cycle before enforcing
Expected Outcomes
- The target policy deployed in report-only mode against production traffic
- Extension-generated noise filtered out before triage
- Genuine violations resolved by changing the application in most cases
- Enforcement switched on after a quiet period, with reporting retained
Step 1: Ship the Policy You Want to End With
Do not start loose and tighten. Start at the target and discover what it breaks, because a policy that starts permissive tends to stay permissive.
Content-Security-Policy-Report-Only:
default-src 'none';
script-src 'nonce-{{RANDOM}}' 'strict-dynamic';
style-src 'self';
img-src 'self' data: https://cdn.example.com;
connect-src 'self' https://api.example.com;
font-src 'self';
frame-ancestors 'none';
base-uri 'none';
form-action 'self';
report-uri https://reports.example.com/csp;
report-to csp-endpoint
Two directives are worth noticing because they are cheap and frequently omitted. base-uri 'none' stops an injected base element rewriting where every relative script URL resolves to. form-action 'self' stops an injected form posting credentials to another origin. Neither breaks anything on a typical application, and both close real attack paths.
Step 2: Filter the Noise Before Anyone Triages
# collect.py — classify first, triage second.
EXTENSION_SCHEMES = ("chrome-extension:", "moz-extension:", "safari-extension:", "webkit-masked-url:")
def classify(report: dict) -> str:
r = report.get("csp-report", report)
blocked = (r.get("blocked-uri") or "").lower()
source = (r.get("source-file") or "").lower()
if blocked.startswith(EXTENSION_SCHEMES) or source.startswith(EXTENSION_SCHEMES):
return "extension" # not your code, not your problem
if blocked in ("about", "about:blank", ""):
return "unattributable" # usually an extension too
if urlparse(r.get("document-uri", "")).netloc not in OUR_HOSTS:
return "not-ours" # a page that is not yours, reporting to you
return "actionable"
On a consumer site the extension category is routinely eighty to ninety-five per cent of the volume. Filtering it before triage is the difference between a rollout that finishes and one where everybody stops reading the dashboard in the second week.
Step 3: Fix the Application, Not the Policy
Every actionable violation has two possible resolutions, and the default should be the first.
## Triage decisions from week one
| Violation | Resolution |
|---------------------------------------------|-----------------------------------------------------|
| Inline onclick in checkout.html | Move to an event listener in the bundle |
| Inline style attribute set by the date picker| Replace with a class; upgrade the library |
| Analytics tag loaded from a vendor domain | Add the exact domain to script-src — genuinely needed|
| Inline script computing a feature flag | Serve the value as a data attribute and read it |
| Legacy widget requiring eval | Isolate in an iframe on the content origin |
Three of those five are application fixes, one is a legitimate policy addition, and one is an isolation decision. That ratio is typical, and it is the point of the exercise: report-only mode is not a way to discover what to add to the policy, it is a way to discover what to fix in the page.
When something genuinely has to be allowed, allow the narrowest possible thing. A hash for one known inline script is much better than an inline allowance for the whole document, and an exact host is much better than a wildcard.
Step 4: Enforce When the Reports Go Quiet
# Both headers, briefly, during the switch.
Content-Security-Policy:
default-src 'none'; script-src 'nonce-{{RANDOM}}' 'strict-dynamic'; …; report-uri /csp
Content-Security-Policy-Report-Only:
default-src 'none'; script-src 'nonce-{{RANDOM}}'; …; report-uri /csp-candidate
Enforce the policy that has been quiet, and put the next tightening into a fresh report-only header. That gives you two signals simultaneously: what the enforced policy is blocking in the wild — where a real injection would surface — and what the candidate policy would block if shipped. Roll enforcement out gradually if your platform allows it, starting with a fraction of traffic and watching error rates alongside the violation stream.
Verification
# 1. The report-only header is present and carries a fresh nonce per response.
for i in 1 2; do
curl -sI https://app.example.com/ | grep -i 'content-security-policy-report-only' | md5sum
done
# Expect two different digests — a repeated one means the nonce is not per-response.
# 2. Reports are actually arriving and being classified.
curl -s https://reports.example.com/api/summary?window=24h | jq '{extension, unattributable, actionable}'
# 3. The candidate policy is stricter than the enforced one, not the reverse.
python3 tools/compare_policies.py --enforced "$(curl -sI … | grep -i '^content-security-policy:')" \
--candidate "$(curl -sI … | grep -i 'report-only')"
# 4. After enforcing, an injected inline script is genuinely blocked.
# Load a test page with an inline script and confirm it does not execute.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| No reports at all | Endpoint unreachable, or the directive is missing | Verify the endpoint accepts the report content type and returns 204 |
| Overwhelming report volume | Extension noise not filtered | Classify by blocked scheme before triage; report only what is actionable |
| Violations from pages you do not own | Someone copied your header, or a proxy injects it | Filter on the document origin |
| The nonce is identical across responses | Generated once at boot rather than per request | Generate per response and never cache a page containing one |
| Everything breaks when enforcing | Report-only header not identical to the enforced one | Compare the two strings; they must match exactly at the switch |
| Reports stop after enforcing | Reporting directive dropped from the enforced header | Keep reporting on the enforced policy permanently |
Common Implementation Mistakes
Frequently Asked Questions
How long should report-only mode run?
Long enough to cover a full traffic cycle — including a weekend, a release, and any monthly batch — which usually means two to four weeks. But the thing you are waiting for is a signal rather than a duration: when every remaining violation is explained and the unexplained rate has gone flat, you are ready. A quiet week during a holiday period is not the same as a representative one.
Why is most of the report volume useless?
Because browser extensions inject scripts and styles into pages, and those injections violate your policy without your code being involved at all. On a consumer site this is routinely the overwhelming majority of reports. Classify on the blocked scheme — extension schemes, about, and inline injections with no source file — before anyone spends attention on triage, or the handful of genuinely actionable violations will be invisible in the noise.
Should report-only stay on after enforcing?
Keep reporting on the enforced policy, and use a second report-only header whenever you are testing a tighter one. That gives two signals at once: what the enforced policy blocks in the wild, and what the candidate would block if shipped. It is also the cheapest injection detector you will ever deploy, because a real attack against a page surfaces as an unexplained violation on a policy that has otherwise gone quiet.
Related
- Secure HTTP Header Configuration — the parent guide covering the full header set
- Content Security Policy: Nonce vs Hash — choosing the right allowance mechanism per delivery model
- Cross-Site Scripting (XSS) Mitigation — the primary control this policy backs up
- Rolling Out Trusted Types in a Legacy App — the same staged rollout applied to DOM sinks