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.

Where the Report Volume Actually Comes From A typical breakdown of violation reports from production traffic. Browser extensions dominate. A further slice is unattributable, usually extensions in a different form. A small slice comes from pages that are not yours. What remains, often a few per cent, is the actionable set that tells you what enforcement would break. A representative week of reports, by class browser extensions — 78% · not your code, filter before triage unattributable — 14% pages that are not yours — 5% actionable — 3%: this is the set that tells you what enforcement would break

Step 3: Fix the Application, Not the Policy

Every actionable violation has two possible resolutions, and the default should be the first.

Fix the Page, or Widen the Policy — and Prefer the First Most actionable violations are application fixes: an inline handler moved to a listener, an inline style replaced by a class, a computed value served as a data attribute. A minority are genuine policy additions, and those should be the narrowest possible: one hashed script rather than a blanket inline allowance, one exact host rather than a wildcard. Move an inline handler into the bundle — an application fix the common case, and the reason report-only mode is worth running Serve a computed value as a data attribute — an application fix removes the inline script entirely rather than permitting it Add one exact host for a genuinely needed third party — a policy change narrow, reviewed, and written down with the reason Add a blanket inline allowance for one legacy widget — avoid hash that one script, or isolate the widget in a frame instead
## 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.

Four Stages, Each Gated by a Signal Rather Than a Date Stage one deploys the target policy in report-only mode. Stage two filters extension noise so the actionable set is visible. Stage three fixes the application, widening the policy only where genuinely necessary. Stage four enforces, gated not by elapsed time but by the unexplained violation rate reaching zero and staying there. 1 · Report-only the target policy, blocking nothing 2 · Filter extensions out, so the real set is visible 3 · Fix the page widen the policy only where unavoidable 4 · Enforce keep reporting on, stage the rollout The gate between stages 3 and 4 is a signal, not a date: unexplained violations at zero, flat across a full traffic cycle that includes a weekend, a release, and any monthly job.

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.