Blog
February 14, 2023 Marie H.

Content Security Policy: From Report-Only to Enforcement

Content Security Policy: From Report-Only to Enforcement

Photo by <a href="https://unsplash.com/@zulfugarkarimov?utm_source=cloudista&utm_medium=referral" target="_blank" rel="noopener">Zulfugar Karimov</a> on <a href="https://unsplash.com/?utm_source=cloudista&utm_medium=referral" target="_blank" rel="noopener">Unsplash</a>

Content Security Policy: From Report-Only to Enforcement

Early 2022 I was handed a task that sounded simple: "get CSP deployed on our customer-facing products." Eighteen months later we had enforcement headers across seven applications, a violation reporting pipeline, and a significantly reduced XSS attack surface. The process taught me more about how browsers load content than I ever expected to need.

Here's the practical version of what I learned.

What CSP Actually Does

Content Security Policy is a browser-enforced allowlist. You send a header that tells the browser exactly what sources are permitted for scripts, styles, images, fonts, and network connections. If something isn't on the list, the browser blocks it — or, in report-only mode, logs it and keeps going.

The primary threat model is XSS data exfiltration. If an attacker injects a script into your page, CSP can prevent that script from phoning home to an attacker-controlled server. It's not a silver bullet — there are CSP bypasses — but it eliminates whole categories of trivial exfiltration.

Start With Report-Only

Never deploy enforcement first. You will break things.

Content-Security-Policy-Report-Only takes the exact same directives as the enforcement header, but violations are reported rather than blocked. Your application continues to function normally while you learn what your policy is missing.

Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; report-uri /csp-reports

Start tight. default-src 'self' is your baseline — everything must come from the same origin unless you explicitly permit otherwise. You'll get a flood of violations. That's the point.

The Directive Set

The directives that matter most for typical web apps:

default-src 'self' — fallback for anything not explicitly specified. Set this tight.

script-src — controls where JavaScript can load from and whether inline scripts are permitted. This is the most important directive for XSS mitigation.

style-src — same as script-src but for CSS. Inline styles are common (especially from component libraries), so you'll likely need to relax this one.

img-src — image sources. User-generated content apps often pull images from S3 or CDNs. Common to see img-src 'self' https://assets.example.com data: blob:.

connect-src — restricts where fetch(), XMLHttpRequest, WebSocket connections, and EventSource can go. Critical for preventing data exfiltration — if an injected script can't fetch to an attacker's server, it can't send your users' data there.

frame-ancestors — controls which origins can embed your page in an iframe. Use frame-ancestors 'none' to prevent clickjacking. This is the modern equivalent of X-Frame-Options: DENY and takes precedence when both are present.

report-uri / report-to — where to send violation reports. report-uri is the older directive (widely supported), report-to is the newer one that uses the Reporting API. I used both during the transition.

Setting Up a Report Collection Endpoint

The simplest version that actually works — a Flask endpoint that logs violations as structured JSON:

@app.route('/csp-reports', methods=['POST'])
def csp_reports():
    if request.content_type == 'application/csp-report':
        report = request.get_json(force=True)
        logger.info("csp_violation", extra={
            "blocked_uri": report.get("csp-report", {}).get("blocked-uri"),
            "violated_directive": report.get("csp-report", {}).get("violated-directive"),
            "document_uri": report.get("csp-report", {}).get("document-uri"),
            "source_file": report.get("csp-report", {}).get("source-file"),
            "line_number": report.get("csp-report", {}).get("line-number"),
        })
    return '', 204

We shipped this behind our API gateway and piped the structured logs into our ELK stack. Within a day we had dashboards showing violation counts by directive and blocked URI. That visibility is what makes the triage process tractable.

If you don't want to run your own endpoint, there are third-party services (Report URI being the prominent one), but I preferred keeping violation data internal.

The Violation Triage Cycle

A CSP violation report looks like this:

{
  "csp-report": {
    "document-uri": "https://app.example.com/dashboard",
    "blocked-uri": "https://cdn.jsdelivr.net",
    "violated-directive": "script-src",
    "source-file": "https://app.example.com/static/legacy-widget.js",
    "line-number": 47
  }
}

blocked-uri tells you what was blocked. violated-directive tells you which rule caught it. source-file and line-number tell you where in your code it's coming from.

The triage cycle: look at violations, decide whether each blocked resource is legitimate, update the policy to permit the legitimate ones, redeploy, watch violation counts drop. Repeat until violations are near zero or fully understood.

It usually takes three to five iterations per application before the policy is stable enough to enforce.

Common Violation Sources

Inline scripts<script>alert('hello')</script> directly in HTML violates script-src unless you add 'unsafe-inline', which defeats most of the XSS protection. The right fix is moving the script to an external file. If the inline script is third-party or generated, use nonces (see below).

eval() usage — any use of eval(), new Function(), or similar triggers a violation of script-src. The right fix is eliminating eval. If you can't — some libraries use it — you add 'unsafe-eval' to script-src and document why. We had one legacy application where a charting library used eval internally; we added 'unsafe-eval' with a comment pointing to the open issue on the library's GitHub tracker.

CDN assets — your app loads jQuery from cdn.jsdelivr.net, fonts from fonts.googleapis.com, analytics from www.google-analytics.com. These all need explicit entries:

script-src 'self' https://cdn.jsdelivr.net;
style-src 'self' https://fonts.googleapis.com;
font-src 'self' https://fonts.gstatic.com;

Third-party widgets — chat widgets, support tools, marketing pixels. These are the messy ones. They often inject further subresources dynamically, so you can't enumerate them statically. Get a complete list of domains from the vendor.

The Nonce Approach for Inline Scripts

If you can't move inline scripts to external files, nonces are the right solution. The server generates a cryptographically random value per request, includes it in the header, and includes the same value on each <script> tag. The browser only executes scripts that match the nonce in the header.

In Go:

func generateNonce() string {
    b := make([]byte, 16)
    rand.Read(b)
    return base64.StdEncoding.EncodeToString(b)
}

// In your middleware:
nonce := generateNonce()
ctx := context.WithValue(r.Context(), nonceKey, nonce)
w.Header().Set("Content-Security-Policy-Report-Only",
    fmt.Sprintf("script-src 'self' 'nonce-%s'; report-uri /csp-reports", nonce))

In your templates:

<script nonce="{{ .Nonce }}">
  // your inline script
</script>

The nonce must be different on every page load — if it's static, an attacker who finds it can inject a matching nonce= attribute. This approach is more work to maintain but avoids 'unsafe-inline'.

Moving from Report-Only to Enforcement

The moment you flip from Content-Security-Policy-Report-Only to Content-Security-Policy, violations block execution instead of just reporting. A missing CDN domain in your script-src goes from a log entry to a broken page.

We did this per-application. One application at a time, we'd run report-only for at least two weeks (covering a full usage cycle), drive violations to zero or documented exceptions, then flip to enforcement on a Tuesday morning with the team watching dashboards.

The first enforcement deployment we ever did, a misconfigured WebSocket connection showed up in the violations that we hadn't caught in report-only mode — apparently WebSocket upgrade requests weren't being reported consistently in the browser version most of our users were on. Keep reporting enabled even in enforcement mode; you'll still catch things.

The header when in enforcement with reporting:

Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://assets.example.com; connect-src 'self' https://api.example.com; frame-ancestors 'none'; report-uri /csp-reports

Note 'unsafe-inline' in style-src — inline styles are extremely common and the security impact is lower than for scripts, so this is often a pragmatic compromise.

frame-ancestors as X-Frame-Options Replacement

One quick win from this project: frame-ancestors 'none' in your CSP replaces X-Frame-Options: DENY. They do the same thing, but CSP's frame-ancestors takes precedence when both are present and is more granular (you can list specific allowed origins).

If you still need to support very old browsers, keep both headers. But frame-ancestors is the direction forward.


The whole initiative took longer than I expected — primarily because coordinating with application teams to fix inline scripts and eval usage takes time, not because the header itself is complex. Start with report-only, instrument your violation collection, and treat the triage cycle as an ongoing process rather than a one-time fix. The enforcement flip at the end is anticlimactic if you've done the work.