Rolling Out a Script Policy in Report-Only Mode

Permalink to "Rolling Out a Script Policy in Report-Only Mode"

Part of Unified Script Policy Architecture, this page covers how to get a strict script policy into production without a breakage window: two headers on every response, a staged sequence with written exit criteria, and a rollback lever you can pull in seconds.

Quick Reference

Permalink to "Quick Reference"
Header or directive Value Effect Support
Content-Security-Policy policy string Blocks violations and reports them All modern browsers
Content-Security-Policy-Report-Only policy string Reports violations, blocks nothing All modern browsers
Reporting-Endpoints name="https://…" Names the endpoints report-to refers to Chromium; not Firefox or Safari
report-to endpoint name Sends reports as application/reports+json Chromium
report-uri absolute or relative path Sends application/csp-report; ignored when report-to is present Firefox, Safari, Chromium
require-trusted-types-for 'script' Guards DOM injection sinks Chromium; Firefox behind a preference
trusted-types policy names Restricts which Trusted Types policies may be created Chromium
disposition (report field) enforce or report Says which header produced the report Both report formats

Rollout order: report-only wide, triage, tighten, enforce on a canary, enforce everywhere.

The mental model: two policies, one page load

Permalink to "The mental model: two policies, one page load"

A response may carry both header fields at once, and they are evaluated independently. Every policy listed in Content-Security-Policy is enforced — a violation blocks the load or the sink write and emits a report. Every policy listed in Content-Security-Policy-Report-Only is monitored — a violation changes nothing about page behaviour and emits a report. The two streams are distinguishable at the collector because each report carries a disposition field whose value is enforce or report.

That gives you a safe way to run an experiment in production. The enforcing header keeps whatever guarantees you already ship, typically a loose policy inherited from an earlier project. The report-only header carries the policy you actually want: nonce-based script-src with 'strict-dynamic', no 'unsafe-inline', no wildcard hosts, object-src 'none', base-uri 'none', and Trusted Types on top. Real users, on real devices, with real extensions installed, tell you exactly which of those rules would have broken the site. You are not guessing from a staging environment that nobody visits with a password manager installed.

The trap is treating the report-only header as a permanent fixture. A policy that reports for eighteen months protects nobody. The discipline that makes this work is committing, up front, to a fixed number of stages, each with an exit criterion written down before the stage starts, and keeping the report-only header always one notch stricter than the enforcing one so there is a candidate policy in flight at all times.

Dual-header evaluation A single HTTP response carries an enforcing Content-Security-Policy header and a Content-Security-Policy-Report-Only header; both are evaluated against the same script load, one blocking with disposition enforce and one allowing with disposition report. HTTP response headers Content-Security-Policy enforcing, shipped Content-Security-Policy- Report-Only, candidate script load or DOM sink write blocked, report sent disposition: enforce allowed, report sent disposition: report

Canonical example: both headers on one response

Permalink to "Canonical example: both headers on one response"

This Express middleware emits the enforcing policy, the candidate report-only policy, and the Reporting-Endpoints header that both refer to. The per-request nonce is generated once and reused in both policies and in the templates, which is the pattern described in Generating Per-Request CSP Nonces.

// middleware/script-policy.js
import { randomBytes } from 'node:crypto';

const REPORT_HOST = 'https://reports.example.com';

// Flip these two constants to move a stage forward or to roll back.
const ENFORCED_STAGE = 'legacy';   // 'legacy' | 'strict'
const CANARY_PERCENT = 0;          // 0-100, share of traffic enforcing 'strict'

const legacyPolicy = (nonce) => [
  "default-src 'self'",
  `script-src 'self' 'unsafe-inline' https://cdn.example.com`,
  "object-src 'none'",
  'report-uri /csp/enforce',
  'report-to csp-enforce',
].join('; ');

const strictPolicy = (nonce) => [
  "default-src 'self'",
  `script-src 'nonce-${nonce}' 'strict-dynamic' https:`,
  "object-src 'none'",
  "base-uri 'none'",
  "frame-ancestors 'none'",
  "require-trusted-types-for 'script'",
  'trusted-types default dompurify',
].join('; ');

export function scriptPolicy(req, res, next) {
  const nonce = randomBytes(16).toString('base64');
  res.locals.nonce = nonce;

  const enforceStrict =
    ENFORCED_STAGE === 'strict' ||
    (CANARY_PERCENT > 0 && hashBucket(req) < CANARY_PERCENT);

  res.setHeader(
    'Reporting-Endpoints',
    `csp-enforce="${REPORT_HOST}/csp/enforce", ` +
      `csp-report-only="${REPORT_HOST}/csp/report-only"`,
  );

  const enforced = enforceStrict
    ? `${strictPolicy(nonce)}; report-uri /csp/enforce; report-to csp-enforce`
    : legacyPolicy(nonce);
  res.setHeader('Content-Security-Policy', enforced);

  // Always keep a candidate one notch ahead of what is enforced.
  if (!enforceStrict) {
    res.setHeader(
      'Content-Security-Policy-Report-Only',
      `${strictPolicy(nonce)}; report-uri /csp/report-only; report-to csp-report-only`,
    );
  }

  next();
}

function hashBucket(req) {
  const id = req.cookies?.visitor_id ?? req.ip ?? '';
  let h = 0;
  for (let i = 0; i < id.length; i += 1) h = (h * 31 + id.charCodeAt(i)) % 100;
  return h;
}

On the wire, a mid-rollout response looks like this. Both header fields are present, each names its own endpoint, and each carries its own report-uri fallback for browsers that do not implement the Reporting API.

HTTP/2 200
content-type: text/html; charset=utf-8
reporting-endpoints: csp-enforce="https://reports.example.com/csp/enforce", csp-report-only="https://reports.example.com/csp/report-only"
content-security-policy: default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.example.com; object-src 'none'; report-uri /csp/enforce; report-to csp-enforce
content-security-policy-report-only: default-src 'self'; script-src 'nonce-r4Kd9vQm2Xp7' 'strict-dynamic' https:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; require-trusted-types-for 'script'; trusted-types default dompurify; report-uri /csp/report-only; report-to csp-report-only

The markup the strict policy is designed to accept keeps its nonce, and any third-party script keeps both an integrity attribute and the crossorigin attribute that makes the integrity check possible:

<script nonce="r4Kd9vQm2Xp7"
        src="https://cdn.example.com/app.9f2c.js"
        integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
        crossorigin="anonymous"></script>

The staged sequence

Permalink to "The staged sequence"

Five stages, in order, each ending on a written criterion rather than on a calendar date. The point of the sequence is that every stage removes one class of unknown before the next stage takes a risk.

Five-stage rollout timeline A left-to-right timeline of five stages: report-only wide, triage and filter reports, tighten the policy, enforce on a canary, and enforce everywhere, each labelled with an approximate week and the criterion that ends it. week 1 report-only, wide nothing blocked exit: reports flow from every edge weeks 2-3 triage and filter the report stream exit: noise gone, backlog triaged weeks 3-5 tighten policy, still report-only exit: no first-party reports for 7 days week 6 enforce on canary 1 to 5% of traffic exit: no delta in JS error rate week 7+ enforce everywhere rollback lever armed exit: next candidate policy is in flight

The stage table below is the version to paste into a rollout ticket. Durations are typical for a site with a weekly release train; substitute your own cycle length, but keep the ordering and keep the exit criteria as evidence rather than dates.

Stage Duration Headers in play Exit criterion
1. Report-only wide 3-7 days Legacy enforcing + strict report-only Reports arriving from every edge region and every supported browser family
2. Triage 5-10 days Unchanged Noise filters in place; every remaining report class has an owner
3. Tighten 7-14 days Report-only policy narrowed after each fix Zero first-party violations for seven consecutive days across a full release
4. Canary enforce 2-5 days Strict enforcing for 1-5% of sessions No measurable delta in JavaScript error rate, conversion, or support tickets
5. Full enforce ongoing Strict enforcing everywhere; new candidate report-only A stricter candidate policy is already reporting

Stage 3 is where most of the calendar goes, and that is correct. Each fix — a moved inline handler, a removed eval, a third-party tag replaced with a self-hosted copy — is a code change that has to ship through your normal release process. Narrow the report-only policy only after the fix is live, never in the same deploy, so a regression is attributable.

Triaging the report stream

Permalink to "Triaging the report stream"

The first day of stage 1 will produce more reports than the rest of the rollout combined, and the overwhelming majority are worthless. Browser extensions inject scripts and inline styles into every page they touch, and those injections violate your policy exactly as a real attacker’s would. Ad blockers, password managers, translation add-ons, coupon tools, accessibility overlays and enterprise device agents all show up. You cannot fix any of them from the server, so the only correct action is to identify them and drop them before they reach a dashboard.

The reliable discriminator is the scheme in sourceFile and blockedURL: chrome-extension:, moz-extension: and safari-web-extension: are unambiguous. After that filter, a second, larger class remains — inline event handlers such as onclick baked into legacy server-rendered templates. These are genuine first-party violations, they report with effectiveDirective set to script-src-attr, and clearing them is the substance of stage 3. The mechanics of moving them out of markup are covered in Migrating from unsafe-inline to Hash-Based CSP.

Report triage decision tree A decision tree that first asks whether a violation report came from a browser extension scheme and drops it if so, then asks whether the offending sample comes from first-party templates, routing to a code fix or to an incident investigation. violation report does sourceFile or blockedURL use an extension scheme? yes drop: injected on the client no does the sample map to our own templates? yes fix the source, then narrow the policy no unknown origin: treat as a security incident

Once reports are landing in a table, triage is a grouping query rather than a reading exercise. This one collapses a week of raw rows into the short list of distinct problems worth assigning, with the extension noise excluded and the report-only stream separated from the enforcing stream:

SELECT
  effective_directive,
  COALESCE(NULLIF(blocked_url, ''), 'inline') AS blocked,
  regexp_replace(source_file, '\?.*$', '')     AS source,
  substr(sample, 1, 40)                        AS first_sample,
  count(*)                                     AS hits,
  count(DISTINCT session_id)                   AS sessions
FROM csp_reports
WHERE received_at > now() - interval '7 days'
  AND disposition = 'report'
  AND source_file NOT LIKE 'chrome-extension:%'
  AND source_file NOT LIKE 'moz-extension:%'
  AND source_file NOT LIKE 'safari-web-extension:%'
  AND blocked_url NOT LIKE '%-extension:%'
GROUP BY 1, 2, 3, 4
HAVING count(DISTINCT session_id) > 5
ORDER BY sessions DESC
LIMIT 50;

The count(DISTINCT session_id) column is what makes the output actionable. A violation seen five thousand times in six sessions is one user with an unusual extension; a violation seen six hundred times across six hundred sessions is a template you ship. Sorting by sessions rather than raw hits puts the real work at the top. Details of endpoint schema, retention and ingest belong to Collecting CSP Violation Reports with the Reporting API.

Variants

Permalink to "Variants"

Trusted Types in report-only mode

Permalink to "Trusted Types in report-only mode"

Trusted Types has no separate report-only header of its own — it rides the same Content-Security-Policy-Report-Only field. Put require-trusted-types-for 'script' there and every assignment to a guarded DOM sink produces a report while still executing normally, so a legacy innerHTML call keeps working during the observation window. Reports arrive with effectiveDirective set to require-trusted-types-for, blockedURL set to trusted-types-sink, and sample set to the sink name followed by the first forty characters of the assigned value, which is usually enough to locate the call site. Adding the trusted-types directive additionally reports every attempt to create a policy whose name is not on the list, which is how you discover that a vendor bundle quietly creates its own. Enforcement details are in Enforcing require-trusted-types-for script, and the sanitizer policy those sinks route through is covered in Writing a Trusted Types Policy with DOMPurify.

Static origins with nginx

Permalink to "Static origins with nginx"

If a nonce is not available because the HTML is static, run the report-only stage with a hash-based or host-based policy instead. The dual-header pattern is identical:

map $rollout_enforce $strict_enforced {
    default "";
    "1"     "default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'; report-uri /csp/enforce";
}

add_header Reporting-Endpoints 'csp-report-only="https://reports.example.com/csp/report-only"' always;
add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'; require-trusted-types-for 'script'; report-uri /csp/report-only; report-to csp-report-only" always;

Remember that add_header in nginx is not inherited into a location block that defines its own add_header directives — repeat them or use include to avoid losing the policy on a subset of routes.

The rollback lever

Permalink to "The rollback lever"

The rollback lever must not be a deploy. Keep the enforcing policy behind a runtime flag read on every request — the ENFORCED_STAGE and CANARY_PERCENT constants above become a config lookup — so an on-call engineer can move all traffic back to the legacy policy in one edit. Two things make this fail in practice: HTML cached at the CDN with the strict header baked in, and a service worker serving a cached HTML shell. Verify before stage 4 that your HTML responses carry Cache-Control: no-store or private, or that a purge is part of the documented rollback runbook, and confirm the rollback path end to end with a curl check rather than assuming it.

Gotchas and Edge Cases

Permalink to "Gotchas and Edge Cases"
  • Chrome ignores report-uri when report-to is present. Per CSP Level 3, a user agent that supports report-to must ignore report-uri in the same policy. Chromium honours this, so the moment you add report-to your legacy collector stops receiving Chrome traffic while Firefox and Safari keep using report-uri. Keep both directives, but make the collector accept both application/csp-report and application/reports+json, and never compute a browser-share metric from a single format.

  • A report-to endpoint name with no Reporting-Endpoints entry fails silently. There is no console warning and no fallback. A typo, or the Reporting-Endpoints header being stripped by a proxy, produces the same symptom as a working rollout with zero violations, which is the most dangerous failure mode this process has. Stage 1’s exit criterion exists precisely to rule it out — you must see a deliberately triggered report arrive before you trust an empty dashboard.

  • Two Content-Security-Policy headers both enforce. If a framework and a reverse proxy each add one, the browser applies both, and the effective result is their intersection — the strictest of each directive wins. That is a common way to accidentally enforce a policy you believed was report-only. Check with curl -I at the edge, not in local development.

  • An integrity attribute without crossorigin="anonymous" blocks the load outright. A cross-origin script with integrity but no crossorigin attribute is fetched in no-cors mode, yielding an opaque response the browser cannot hash, so it fails the integrity check and never executes. During a report-only rollout this looks like a policy regression but is entirely independent of CSP: no report is emitted, only a console error. Add crossorigin="anonymous" to every tag that carries integrity, and make sure the origin serves Access-Control-Allow-Origin.

  • Cross-origin report endpoints need a CORS preflight. Reporting API deliveries are POST requests with Content-Type: application/reports+json, which is not a simple content type, so a collector on a different origin must answer the OPTIONS preflight with Access-Control-Allow-Origin and Access-Control-Allow-Headers: Content-Type. Same-origin paths such as /csp/report-only avoid the problem entirely and are the safer default.

  • Report bodies are deliberately lossy. Cross-origin blockedURL values are truncated to the origin, sample is capped at forty characters, and browsers deduplicate identical reports within a document. Do not build a rollout gate that requires reproducing an exact URL or a full script body from a report; use the report to identify a class of problem and reproduce it locally.

Verification Steps

Permalink to "Verification Steps"

1. Confirm both headers reach the browser

Permalink to "1. Confirm both headers reach the browser"
curl -sI https://www.example.com/ | grep -i -E 'content-security-policy|reporting-endpoints'

Expected output during stages 1-3: three lines — a content-security-policy, a content-security-policy-report-only, and a reporting-endpoints line whose endpoint names exactly match the names used in both report-to directives.

2. Force a violation and confirm the report lands

Permalink to "2. Force a violation and confirm the report lands"

Load a page and run this in the console. It triggers the report-only policy without changing anything a user sees:

// Violates script-src in the report-only policy; nothing is blocked.
document.head.appendChild(
  Object.assign(document.createElement('script'), {
    src: 'https://example.invalid/probe.js',
  }),
);

Then check the collector. Reporting API deliveries are batched and can lag by up to a minute, so poll rather than expecting an instant row:

curl -s "https://reports.example.com/api/recent?minutes=5" | jq '.[] | {disposition: .body.disposition, directive: .body.effectiveDirective, blocked: .body.blockedURL}'

Expected output:

{
  "disposition": "report",
  "directive": "script-src-elem",
  "blocked": "https://example.invalid"
}

A disposition of report proves the report-only header is the one that fired. If you see enforce, the strict policy is already in the enforcing header and you are further along the rollout than you thought.

3. Check the first-party violation count before promoting

Permalink to "3. Check the first-party violation count before promoting"
psql -qtAX -d telemetry -c "SELECT count(DISTINCT session_id) FROM csp_reports WHERE received_at > now() - interval '7 days' AND disposition = 'report' AND source_file NOT LIKE '%-extension:%'"

Expected output for a stage-3 exit: 0. Any non-zero value names sessions where the candidate policy would have broken the page, so the stage continues.

4. Prove the rollback lever works

Permalink to "4. Prove the rollback lever works"
# Flip the runtime flag back to the legacy policy, then re-check the edge.
curl -sI https://www.example.com/ | grep -ci "unsafe-inline"

Expected output: 1, within one cache TTL of the flag change. If it stays 0, your HTML is cached with the strict header and rollback requires a purge — fix that before stage 4, not during an incident. Once the full policy is enforced everywhere, keep the pipeline pointed forward: the same report stream drives Auditing a Script Policy for Gaps, and the layered design the candidate policy is converging on is described in Layering CSP Nonces, SRI and Trusted Types.

Frequently Asked Questions

Permalink to "Frequently Asked Questions"
Can one response carry both an enforcing and a report-only CSP header?

Yes. Content-Security-Policy and Content-Security-Policy-Report-Only are separate header fields with separate policy lists. The browser enforces every policy in the first and only monitors every policy in the second. That is exactly the mechanism a staged rollout depends on: production keeps its current guarantees while the candidate policy reports what it would have blocked.

Why do my report-to reports never arrive?

Almost always the endpoint name in report-to has no matching entry in the Reporting-Endpoints header, and the browser drops the reports silently. The other common causes are a cross-origin collector that fails the CORS preflight for application/reports+json, and Chrome ignoring report-uri because report-to is also present. Test each browser separately.

How do I tell a browser extension's violation from my own?

Check sourceFile and blockedURL for the chrome-extension, moz-extension and safari-web-extension schemes and drop those rows. What survives is usually inline handlers in your own templates, which carry your document’s origin in sourceFile, or a genuinely unknown host. Never change your policy to accommodate an extension; you cannot fix client-side injection from the server.

Does Trusted Types have its own report-only mode?

It uses the same mechanism. Put require-trusted-types-for ‘script’ and the trusted-types directive in the report-only header and every unsafe sink assignment produces a violation report with effectiveDirective set to require-trusted-types-for while still executing. The sample field carries the sink name and the first forty characters of the assigned value.

How long should each rollout stage run?

Long enough to cover one full traffic cycle plus one release. For most sites that is a week per stage: weekday and weekend traffic, at least one deploy, and the monthly batch jobs if you have them. Stages end on evidence, not on dates, so a stage that still produces new first-party violations simply keeps running.

Permalink to "Related"

Related Articles

Layering CSP Nonces, SRI and Trusted Types
Auditing a Script Policy for Gaps
Unified Script Policy Architecture Runtime Policy Enforcement & T…