content security policycsp report onlyweb securityxss preventionhttp headers

Content-Security-Policy Report Only: A Practical Guide

Learn how to use Content-Security-Policy Report Only mode to safely test and deploy a CSP. This guide covers setup, report endpoints, parsing, and enforcement.

Published May 27, 2026 · Updated May 27, 2026

Content-Security-Policy Report Only: A Practical Guide

A lot of teams reach for CSP after a pen test, a browser console warning, or an uncomfortable question from a customer security review. Then they ship an aggressive policy, break analytics, payments, fonts, or half the front end, and remove it a day later.

That's why Content-Security-Policy Report-Only matters. It gives you a controlled way to learn how your application behaves before the browser starts blocking anything. The header has been supported in mainstream browsers since 2013, including Chrome 25+, Firefox 23+, Safari 7+, and later Edge 12+, which is why it's a practical foundation rather than an experimental feature, as noted by Foundeo's CSP report-only reference.

Most guides stop at the header string. The actual work starts after that. If you don't know how to collect, filter, group, and act on violation data, report-only mode turns into a noisy mailbox that nobody trusts. If you do it properly, it becomes one of the safest ways to tighten client-side security without turning production into a test environment.

Safely Auditing Your Site with CSP Report-Only

The failure mode is familiar. A team writes a policy that looks sensible on paper, deploys it to production, and discovers that the app depends on inline scripts, a tag manager, a payment widget, two font providers, and an API endpoint that nobody documented. Users don't care that the policy is “more secure” if checkout stops working.

Content-Security-Policy Report-Only exists to prevent that kind of rollout. Browsers evaluate the candidate policy, detect violations, and send reports while still loading the resource normally. That changes CSP from a risky all-or-nothing switch into an audit process.

Why report-only is the professional starting point

Report-only mode is valuable because it shows you what a restrictive policy would break without creating immediate user impact. That makes it the right first step for dynamic products, especially the kind of web apps that talk to multiple APIs, use hosted auth, and pull in third-party SDKs.

For teams that want a broader baseline before touching headers, ARPHost's website security guide is a useful companion resource because it puts CSP in the wider context of browser-facing hardening, transport security, and operational hygiene.

A useful mental model is this:

  • Enforcement answers whether the browser should block.
  • Report-only answers whether your policy is accurate.
  • Your pipeline answers whether the data is usable.

That third part gets missed constantly.

What you're actually auditing

A report-only deployment helps you surface three things early:

  • Undocumented dependencies: Scripts, styles, frames, fonts, and API calls that the app needs but the team never wrote down.
  • Unsafe implementation patterns: Inline code, string-based JavaScript execution, and ad hoc third-party embeds that don't fit a tighter policy.
  • Environment drift: Staging, preview, and production often load different assets. Report-only exposes those differences before enforcement punishes them.

Practical rule: If you can't explain every host in your future allowlist, your policy isn't ready.

This is why I treat report-only as a discovery tool first and a protection mechanism second. It gives you visibility into real behaviour, not just intended architecture.

If you're dealing with a legacy estate or a fast-moving startup app, it also helps to run a broader security audit of websites before narrowing down CSP work. CSP is strongest when it's part of a bigger clean-up, not a lonely header pasted into production.

Where teams go wrong

The most common mistake isn't technical. It's operational. Teams enable report-only, see a flood of violations, and assume the policy is impossible.

Usually the opposite is true. The early flood means your application has accumulated unreviewed client-side behaviour. Report-only is doing its job. The answer isn't to give up and allow everything. The answer is to separate legitimate traffic from noise, then tighten the policy in stages.

Crafting Your Initial Report-Only Policy

The first draft should answer one question clearly. What will this app try to load if we stop trusting everything by default?

That is why the initial policy needs restraint. A policy that is too loose hides real dependencies. A policy that tries to model every exception on day one turns into a long allowlist nobody wants to review six weeks later.

Start with the HTTP response header, not a <meta> tag. The header is easier to deploy consistently across routes, easier to version in infrastructure, and better suited to report collection. If you run multiple apps behind the same CDN or reverse proxy, this also gives you one place to inspect changes before they spread.

Crafting Your Initial Report-Only Policy

A sensible baseline header

This is a practical starting point for many server-rendered or moderately complex apps:

Content-Security-Policy-Report-Only:
  default-src 'self';
  script-src 'self';
  style-src 'self';
  img-src 'self' data: https:;
  font-src 'self' data:;
  connect-src 'self';
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'none';
  form-action 'self';
  report-uri /csp-report

This baseline works because it is opinionated without being fragile.

  • default-src 'self' sets a clear default for any fetch type you did not spell out yet.
  • script-src 'self' and style-src 'self' force the reports to expose inline code, third-party tags, and framework shortcuts that will block enforcement later.
  • img-src 'self' data: https: keeps image loading practical. Many apps still depend on data URLs for generated assets and on HTTPS hosts for user content or marketing systems.
  • connect-src 'self' shows you every analytics endpoint, API domain, feature flag service, and websocket origin the front end is calling.
  • object-src 'none', base-uri 'self', frame-ancestors 'none', and form-action 'self' close off classes of abuse that rarely need broad exceptions in modern apps.

Keep the first version readable. If a developer cannot look at the header and explain why each directive exists, the rollout will stall when reports start arriving.

report-uri and report-to

You have two reporting models to choose from. report-uri is the older CSP mechanism. report-to works with the Reporting API and a named endpoint group. In production, many teams send both for a while because browser support and existing ingestion tooling do not line up cleanly.

| Feature | report-uri (Legacy) | report-to (Modern) | |---|---|---| | Delivery model | Direct CSP reporting endpoint | Reporting API endpoint group | | Header style | Added inside the CSP policy | Paired with Reporting-Endpoints | | Compatibility mindset | Useful for older integrations | Better fit for newer telemetry pipelines | | Operational flexibility | Simpler to start with | Better for structured reporting setups | | Recommended use | Good for quick setup or legacy support | Better long term if your stack supports it cleanly |

Use that trade-off deliberately. If the team needs reports flowing this week, report-uri is often the fastest path. If you already have a reporting pipeline and want cleaner endpoint management, add Reporting-Endpoints and report-to early.

Copy-paste examples

Legacy style:

Content-Security-Policy-Report-Only:
  default-src 'self';
  script-src 'self';
  style-src 'self';
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'none';
  report-uri https://app.example.com/csp-report

Modern Reporting API style:

Reporting-Endpoints: csp-endpoint="https://app.example.com/csp-report"

Content-Security-Policy-Report-Only:
  default-src 'self';
  script-src 'self';
  style-src 'self';
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'none';
  report-to csp-endpoint

The goal here is not to predict every host up front. The goal is to create a policy that surfaces real client-side behavior in a form your team can review, group, and either fix or approve later.

Don't start by allowlisting every CDN and widget you can think of. Start narrow and let the reports show what the application actually depends on.

Header versus meta tag

Use a response header unless there is a hard platform constraint.

A <meta http-equiv="Content-Security-Policy"> policy is harder to manage at scale because it lives in markup, arrives later in the document lifecycle, and does not fit well with infrastructure-based deployment controls. It also makes policy drift more likely across templates and frontend entry points. If your app has a web server, framework middleware, CDN rules, or platform config, put CSP there.

Setting Up an Endpoint to Collect Violation Reports

A report-only policy without a collection endpoint is just theatre. The browser may detect violations, but if those reports don't land somewhere queryable, nobody learns anything.

The endpoint itself is simple. The hard part is treating it as telemetry, not as a debug hack.

Setting Up an Endpoint to Collect Violation Reports

A minimal Express endpoint

This example accepts both the older application/csp-report shape and ordinary JSON. That helps when browsers or proxy layers differ in how they submit payloads.

const express = require('express');

const app = express();

// Parse legacy CSP reports
app.use('/csp-report', express.json({ type: ['application/csp-report', 'application/json'] }));

app.post('/csp-report', (req, res) => {
  const body = req.body || {};
  const report = body['csp-report'] || body;

  const event = {
    documentUri: report['document-uri'],
    blockedUri: report['blocked-uri'],
    violatedDirective: report['violated-directive'],
    effectiveDirective: report['effective-directive'],
    originalPolicy: report['original-policy'],
    sourceFile: report['source-file'],
    lineNumber: report['line-number'],
    columnNumber: report['column-number'],
    userAgent: req.get('user-agent')
  };

  console.log(JSON.stringify(event));
  res.status(204).end();
});

app.listen(3000, () => {
  console.log('CSP report collector listening on port 3000');
});

This is enough to get started, but it isn't enough for production. In production you'll usually want to add:

  • Request size limits: Violation payloads should stay small.
  • Structured logging: Write JSON to a collector, not plain text to stdout forever.
  • Deduplication: Repeat reports can swamp your logs.
  • Authentication or isolation controls: Not because CSP reports are secret, but because you don't want arbitrary junk poisoning your telemetry.

What the endpoint should do

The endpoint's job is narrow. It should accept the POST, parse the JSON, persist what matters, and reply cleanly.

A good collector should:

  1. Return 204 No Content once it accepts the event.
  2. Store raw payloads briefly for troubleshooting.
  3. Extract normalised fields for querying and grouping.
  4. Avoid expensive synchronous work in the request path.

The report endpoint isn't part of your customer-facing feature set. Treat it like observability infrastructure.

Testing the collector before rollout

Test the endpoint directly before you add the CSP header. That removes a lot of uncertainty.

curl -X POST https://app.example.com/csp-report \
  -H "Content-Type: application/csp-report" \
  --data '{
    "csp-report": {
      "document-uri": "https://app.example.com/dashboard",
      "violated-directive": "script-src",
      "effective-directive": "script-src",
      "blocked-uri": "https://cdn.example.net/widget.js",
      "original-policy": "default-src '\''self'\''; script-src '\''self'\''; report-uri /csp-report",
      "source-file": "https://app.example.com/dashboard",
      "line-number": 42,
      "column-number": 13
    }
  }'

If the endpoint returns 204, logs the event, and your normalisation logic works, the plumbing is ready.

Build or buy

You don't have to run your own collector. Teams often send CSP reports into a platform they already trust for operational events, such as Sentry, or use a dedicated reporting service.

Self-hosting is a good fit when you want control over retention, filtering, and grouping logic. A third-party collector is a good fit when you want the shortest path to visibility. The wrong choice is neither. If reports disappear into a black hole, report-only mode becomes background noise and eventually gets ignored.

Implementing CSP on Common Hosting Platforms

A common rollout failure looks like this. The team writes a reasonable report-only policy, adds it in one place, and assumes every HTML response now carries it. A week later, reports are missing for preview builds, a CDN cache serves stale headers, and the login route has a different policy than the app shell.

Hosting choice does not change the CSP lifecycle. It changes where policy lives, how many layers can override it, and how hard it is to prove that every response is covered.

Implementing CSP on Common Hosting Platforms

Start by deciding where the source of truth belongs. For a static site, platform headers are usually enough. For an application with route-specific behavior, set CSP closer to the framework or middleware layer. For systems with both an edge layer and an app server, pick one place as authoritative and test for drift. Two partially managed policies create noisy reports and hard-to-debug gaps.

Netlify

Netlify supports custom response headers through netlify.toml. For static sites and front ends served directly by Netlify, this is usually the simplest place to start.

[[headers]]
  for = "/*"
  [headers.values]
    Content-Security-Policy-Report-Only = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: https:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'; report-uri /csp-report"

The main check is coverage. Confirm the header is present on actual HTML responses for production, branch deploys, and preview URLs. Also verify behavior on custom 404 pages and redirect targets, because those routes often fall outside the path teams test first.

Vercel

Vercel commonly uses vercel.json for headers, although framework configuration can be a better fit if the app already manages routing.

{
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        {
          "key": "Content-Security-Policy-Report-Only",
          "value": "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: https:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'; report-uri /csp-report"
        }
      ]
    }
  ]
}

This works well for a single policy across the site. If you run Next.js and different route groups need different directives, framework headers or middleware are easier to maintain. Marketing pages, authenticated UI, embedded flows, and admin tools often pull scripts and frames from different origins. One global header can become too permissive if it has to satisfy all of them.

Express and Next.js

For Node applications, set CSP near the application logic when you need per-route control or dynamic values such as nonces. Middleware keeps that logic visible to the team shipping the code.

Using helmet in Express is common because it keeps the policy declarative:

const helmet = require('helmet');

app.use(
  helmet({
    contentSecurityPolicy: false
  })
);

app.use((req, res, next) => {
  res.setHeader(
    'Content-Security-Policy-Report-Only',
    "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: https:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'; report-uri /csp-report"
  );
  next();
});

This approach gives developers precise control, but it also creates an operational risk. If a reverse proxy, CDN rule, or hosting platform injects its own CSP header, the effective behavior may differ by environment. Check real responses in staging and production with curl -I or browser devtools, not just application code.

Firebase Hosting and Supabase

Firebase Hosting lets you declare headers in firebase.json, which is usually the right place for a front end served there.

{
  "hosting": {
    "headers": [
      {
        "source": "**",
        "headers": [
          {
            "key": "Content-Security-Policy-Report-Only",
            "value": "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: https:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'; report-uri /csp-report"
          }
        ]
      }
    ]
  }
}

Supabase usually is not the layer serving every page. It is one dependency inside the application. In practice, teams set CSP at the hosting layer for the front end, then allow the Supabase endpoints they use in connect-src, plus any storage, realtime, or Edge Function origins the browser calls.

If you are tightening the service boundary around those browser integrations, this guide to secure a web service is a useful companion because CSP only governs what the browser is allowed to load and execute.

What to validate on any platform

The platform-specific syntax is the easy part. The operational checks are where rollouts stay on track.

Verify that the header is present on every HTML entry point, not just the homepage. Check cached responses, error pages, localized routes, and authenticated paths. Confirm preview and staging environments send reports to a collector you monitor. If your stack has both edge and origin layers, document which one owns CSP and which one must not modify it.

That discipline matters later, when reports start arriving at volume. If the same route emits different policies depending on cache state or deployment path, the report stream becomes harder to trust and much harder to turn into fixable engineering work.

Analysing Reports and Prioritising Fixes

CSP projects succeed or stall depending on how reports are managed. Raw reports are noisy. Useful reports are normalised, grouped, and interpreted in context.

A typical violation payload contains more than enough information to drive action if you read the right fields.

Analysing Reports and Prioritising Fixes

Reading a violation report

A simplified example looks like this:

{
  "csp-report": {
    "document-uri": "https://app.example.com/settings",
    "blocked-uri": "inline",
    "violated-directive": "script-src",
    "effective-directive": "script-src",
    "source-file": "https://app.example.com/settings",
    "line-number": 18,
    "column-number": 7
  }
}

The fields that usually matter most are:

  • document-uri tells you which page emitted the violation.
  • blocked-uri tells you what the browser objected to. That might be a host, inline, eval, or another scheme.
  • violated-directive tells you which rule failed.
  • source-file, line-number, and column-number help developers find the code path when the browser provides them.

The top fixes that usually move the needle

  • Inline scripts and handlers: If blocked-uri is inline, remove inline code where possible. If it must stay, use a nonce for dynamic content or a hash for static snippets.
  • Unexpected third-party hosts: If a script or frame comes from a host you didn't intend to trust, remove it. If it's required, add that host to the specific directive rather than widening the whole policy.
  • Client-side network calls: connect-src violations often reveal undocumented API calls, telemetry destinations, or environment-specific endpoints. Tighten these carefully because overbroad connect-src rules weaken the policy fast.

Reports that mention mixed content, third-party scripts, or browser-specific breakage are often the most useful ones. They reveal real dependency sprawl, not just syntax mistakes.

That's also where aggregation matters. The primary value of CSP reporting is in surfacing mixed content, unexpected third-party scripts, and browser-specific breakage, but the signal can stay sparse until teams invest in filtering and grouping, as highlighted by the ZAP alert guidance on CSP report-only.

How to prioritise instead of drowning

Don't triage report-by-report. Group by a small set of dimensions:

| Group by | Why it helps | |---|---| | violated-directive | Shows which part of the policy needs attention | | blocked-uri | Reveals repeat offenders and hidden dependencies | | document-uri | Tells you which pages or flows are affected |

Then sort fixes into three buckets:

  1. Remove it if the dependency is accidental, obsolete, or risky.
  2. Allow it narrowly if the dependency is legitimate and controlled.
  3. Refactor it if the app relies on inline code or patterns that don't belong in a stricter CSP.

If you're debugging script violations tied to DOM injection or unsafe rendering, examples of cross-site scripting attacks help developers connect the browser report to the exploit class CSP is trying to constrain.

Graduating from Report-Only to Enforcement

A lot of organisations stay in report-only mode too long. They collect telemetry, create a dashboard, and never turn on blocking. At that point CSP becomes compliance wallpaper.

The safer position is to treat report-only as a staging phase with an exit criterion. Browsers evaluate the policy, emit reports, and still load the resource, which is why it's the safest rollout mode. Best practice is to move to enforcement after the violation stream has stabilised, as described by HTTP.dev's guidance on CSP report-only deployment.

What “ready” looks like

You're close to enforcement when the remaining reports are predictable and well understood. That doesn't mean zero reports forever. It means you can explain them.

A policy is usually ready when:

  • Legitimate application behaviour is covered: Core journeys load cleanly.
  • The remaining noise is classified: Browser quirks, extensions, and low-value junk aren't driving decisions.
  • Your team has an owner: Somebody will keep watching reports after enforcement.

The actual switch

The technical change is simple. Replace:

Content-Security-Policy-Report-Only: ...

with:

Content-Security-Policy: ...

Keep the reporting directive in place. Enforcement without reporting leaves you blind when a new dependency appears, a product team adds a widget, or a release reintroduces unsafe patterns.

Enforcement isn't the end of the CSP project. It's when the policy starts carrying operational weight.

Roll it out gradually if the app is large. Start with a lower-risk route, an internal area, or a part of the product with fewer third-party dependencies. Watch reports, watch error rates, and make small corrections instead of one dramatic rewrite.

A mature CSP isn't perfectly strict on day one. It's accurate, maintained, and backed by data.


If you're building on Supabase, Firebase, or shipping a web or mobile front end with too many moving parts to audit by hand, AuditYour.App helps you catch the misconfigurations that CSP reports often point towards later. It's a fast way to find exposed backend rules, leaked secrets, unsafe RPCs, and other security gaps before they show up in production behaviour.

Scan your app for this vulnerability

AuditYourApp automatically detects security misconfigurations in Supabase and Firebase projects. Get actionable remediation in minutes.

Run Free Scan