Content Security Policy report-only: test a CSP and fix what it blocks
A Content Security Policy is one of the most effective protections against injected scripts, but switching one on without testing can break fonts, analytics, embeds, forms and payments in a single deployment. The Content-Security-Policy-Report-Only header solves that by letting the browser report what it would block without blocking anything. This guide explains how report-only mode works, how to find where an existing policy is being set, why a simple default-src 'self' policy so often seems not to work or breaks the site, how to collect and read violation reports from real visitors, and how to move safely from a trial policy to an enforced one on nginx, Apache, Plesk, Cloudflare and WordPress.
- What Content-Security-Policy-Report-Only does
- Where a Content Security Policy is set
- Why default-src 'self' is not working
- How to receive and read violation reports
- Adding the report-only header on common servers
- A worked example: from report-only to enforced
- The fix, step by step
- Edge cases worth knowing
- Keeping the policy healthy
- Questions and answers
What Content-Security-Policy-Report-Only does
A Content Security Policy can be sent in two forms. Content-Security-Policy enforces the rules: the browser refuses to load anything the policy does not allow. Content-Security-Policy-Report-Only takes exactly the same directives, but the browser blocks nothing. It only notes each resource that would have been refused, prints a warning in the Console and, if you give it an address, sends a report.
Content-Security-Policy-Report-Only: default-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'self'; report-uri /csp-reports
That makes report-only the safe way to introduce or tighten a policy on a live site. You see the full list of scripts, styles, fonts, frames and connections your pages actually use, including ones added by plugins and tag managers that nobody remembers, before a single visitor sees a broken page.
You can send both headers at once. A common pattern is to enforce a loose policy you know works while trialling a stricter one in report-only mode. The browser applies them independently.
Where a Content Security Policy is set
One of the most common questions is simply where the policy comes from, because it can be added at several layers and the browser cannot tell you which one sent it. Work through these in order until you find it.
| Layer | Where to look | Typical example |
|---|---|---|
| CDN or proxy | Dashboard rules for response headers | Cloudflare Transform Rules, a Worker, a load balancer policy |
| Web server | Site configuration and include files | add_header in nginx, Header set in Apache or .htaccess |
| Control panel | Per-domain web server settings | Plesk Apache and nginx Settings, additional directives |
| Application | Middleware, framework config, filters | Spring Security, Express helmet, a Laravel middleware |
| CMS plugin | Security or header plugins | A WordPress security plugin with a headers screen |
| Page markup | The <head> of the HTML | <meta http-equiv="Content-Security-Policy"> |
To see what is actually delivered, request the page and look at both the headers and the HTML:
curl -sI https://example.com/ | grep -i content-security
curl -s https://example.com/ | grep -io '<meta[^>]*content-security[^>]*>'
If the header appears on the CDN address but not when you request the origin server directly, the CDN is adding it. If it appears on every site on the server, it is in a global configuration file rather than the site's own. On a Linux server you can search configuration for it:
sudo grep -ril "content-security-policy" /etc/nginx /etc/apache2 /etc/httpd /var/www/vhosts/*/conf 2>/dev/null
The platform guides for nginx, Cloudflare and WordPress show where each one stores headers.
Why default-src 'self' is not working
People often add default-src 'self', see the site break or see nothing change at all, and conclude the policy is not working. It nearly always is working; it is just doing something different from what they expected. These are the usual reasons.
| What you see | Why it happens | What to do |
|---|---|---|
| Inline scripts and styles stop working | 'self' covers files from your own origin, not code written inside the page or in onclick attributes | Move code to files, or use nonces or hashes |
| Google Fonts, analytics or embeds vanish | Every other domain is blocked, including your own CDN and subdomains | Add each host to the right directive, such as font-src |
www and bare domain behave differently | 'self' means the exact scheme, host and port of the page | List the other host, or load everything from one host |
| Your own files are blocked too | self written without single quotes is read as a host called self | Write 'self' with the quotes |
| Nothing is blocked, header present | It was sent as -Report-Only, or it is a meta tag placed after the scripts | Check the header name and position |
| The header is not there | nginx inheritance, a missing always flag, a CDN stripping it, or a cached response | Test the origin and the CDN separately with curl |
| Framing protection not working | default-src does not cover frame-ancestors, base-uri or form-action | Set those three directives explicitly |
A further trap is two policies. If a plugin sends one header and your server sends another, both are enforced, and a resource has to be allowed by both. Adding a host to one of them then appears to make no difference.
'unsafe-inline' to script-src to make inline code work removes most of the protection a CSP gives against injected scripts. Use it for styles only if you must, and treat it as temporary.How to receive and read violation reports
The Console shows violations in your own browser. To see what real visitors' browsers hit, on pages and devices you never test, add a reporting directive. There are two, and it is sensible to send both for now.
Reporting-Endpoints: csp="https://example.com/csp-reports"
Content-Security-Policy-Report-Only: default-src 'self'; object-src 'none'; report-uri https://example.com/csp-reports; report-to csp
report-uri is the older directive and is marked deprecated, but it is still widely supported. report-to names an endpoint from the Reporting-Endpoints header. Browsers that support report-to use it and ignore report-uri; others fall back to report-uri. Reporting directives are ignored in a meta tag, so this needs a real header.
What a report looks like
A report-uri report arrives as a POST with the content type application/csp-report:
{
"csp-report": {
"document-uri": "https://example.com/menu",
"violated-directive": "font-src",
"effective-directive": "font-src",
"blocked-uri": "https://fonts.gstatic.com",
"disposition": "report",
"original-policy": "default-src 'self'; object-src 'none'; report-uri /csp-reports"
}
}
Reports sent through report-to use the content type application/reports+json, arrive as an array, and use camel-case field names such as documentURL, blockedURL and effectiveDirective. A small endpoint only has to accept the POST, write the body to a log and return 204.
Reading them
- Group by effective directive and blocked address. Fifty reports about one font host are one decision, not fifty.
- A blocked value of
inlinemeans inline code,evalmeans string evaluation in a script, anddatameans a data address. - Reports about
chrome-extension,moz-extensionor unknown ad domains usually come from visitors' browser extensions. Ignore them rather than allowing them.
Adding the report-only header on common servers
Use the same header name and policy whichever server you have. Only the syntax changes.
nginx
add_header Content-Security-Policy-Report-Only "default-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'self'; report-uri /csp-reports" always;
The always flag adds the header to error responses as well. Remember that any add_header in a location block replaces those set higher up.
Apache or .htaccess
<IfModule mod_headers.c>
Header always set Content-Security-Policy-Report-Only "default-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'self'; report-uri /csp-reports"
</IfModule>
Plesk
On a Plesk server, open the domain, go to Apache and nginx Settings, and add the nginx line to the additional nginx directives, or the Apache line to the additional HTTP and HTTPS directives. Use one, not both. The Plesk security headers guide shows the screens.
Cloudflare
Create a Response Header Transform Rule that sets Content-Security-Policy-Report-Only to your policy. Check that your origin is not sending its own policy too.
After any change, run curl -sI again and confirm the header appears once, with the exact name and policy you intended.
A worked example: from report-only to enforced
Example Bakery adds default-src 'self'; object-src 'none'; base-uri 'self' as report-only on its WordPress site, with a report-uri pointing to a small logging script. After three days the log holds about 2,400 reports, which group into six lines:
| Directive | Blocked | Decision |
|---|---|---|
| style-src | fonts.googleapis.com | Allow in style-src |
| font-src | fonts.gstatic.com | Allow in font-src |
| script-src | www.googletagmanager.com | Allow in script-src, and its collection host in connect-src |
| script-src | inline | Theme's inline settings script: add a nonce through the theme |
| frame-src | www.youtube-nocookie.com | Allow in frame-src |
| script-src | chrome-extension | Ignore |
They add the five decisions, leave it in report-only mode for another week, and see only extension noise. They then rename the header to Content-Security-Policy, keep report-uri, and watch the log for the first few days after the change.
The fix, step by step
Step 1
Find where any current policy is set
Run curl -sI on the live site and on the origin server if you use a CDN, and search the HTML for a CSP meta tag. Check web server configuration, control panel directives, CDN header rules, application middleware and CMS plugins. Write down every place a policy is set. You are ready to move on when you know exactly which layer sends each policy and you have decided which single layer will own it from now on.
Step 2
Remove duplicate policies
Turn off every copy except the one you chose, for example a security plugin's header screen when the server already sends a policy. Purge any page cache and CDN cache. Run curl again on several URLs, including an error page. You should see no policy at all, or only the one you intend to keep, so that the report-only trial you add next is measuring a clean starting point.
Step 3
Add a report-only header with a starter policy
Send Content-Security-Policy-Report-Only with default-src 'self'; object-src 'none'; base-uri 'self' plus img-src 'self' data: if you use data images. Add it on your chosen layer: nginx, Apache, Plesk, Cloudflare or the application. Make sure 'self' is written with single quotes. Confirm with curl that the header is present once and that the site looks and works exactly as before, because a report-only header should never change what visitors see.
Step 4
Set up a reporting endpoint
Create a small script at a path such as /csp-reports that accepts a POST, writes the body to a log file and returns 204. Add report-uri pointing to it, and optionally report-to with a matching Reporting-Endpoints header. Test it by loading a page that uses an external font: within seconds a report naming font-src should appear in the log. If nothing arrives, check the endpoint path, the response code and any firewall rule blocking POST requests.
Step 5
Collect violations for a few days
Leave the trial running long enough to cover normal traffic, ideally a week, including any pages people visit rarely such as checkout, account and contact forms. Browse them yourself with the Console open as well. Check the endpoint is not slowing pages and that the log is not growing faster than you can store. You are done when new report types stop appearing.
Step 6
Group and decide on each source
Group the reports by effective directive and blocked address. For each group decide whether to allow it, replace it, or ignore it. Add trusted hosts to the narrowest directive that fits, for example font-src for font files. For inline scripts, add nonces or hashes or move the code into files. Ignore extension addresses. Update the report-only policy with your decisions and redeploy it.
Step 7
Confirm the trial goes quiet
Watch the reports for several more days after updating the policy. The only remaining entries should be browser extension noise and sources you deliberately chose to block. If a real feature still appears, fix that source and wait again. Test the main journeys, such as forms, logins and payments, while watching the Console, so that you see any refusals the reports have not yet caught.
Step 8
Switch to the enforcing header
Rename the header from Content-Security-Policy-Report-Only to Content-Security-Policy, keeping the same directives and the reporting directive. Purge caches, then run curl to confirm the new header name is returned on every template. Test the main journeys straight away. If anything breaks, revert to report-only within minutes rather than adding 'unsafe-inline' or wildcards to the enforced policy, then fix the source in report-only mode and try again.
Edge cases worth knowing
- Meta tags cannot be report-only. Browsers ignore
Content-Security-Policy-Report-Onlyin a meta tag, so a report-only trial always needs a real header. - Report volume. A busy site can send thousands of reports an hour. Sample them, rate-limit the endpoint, or use a hosted collector, and never let the endpoint slow down page delivery.
- Reports contain addresses. The document and blocked addresses can include query strings with personal data. Store reports securely and delete them when you are done.
- Cached pages. A full-page cache or CDN can serve old headers for hours. Purge it after each policy change, or the reports describe the old policy.
- Directives that never report. If a directive is missing from the policy and
default-srcdoes not cover it, as withframe-ancestors, nothing is ever reported for it. Add it explicitly.
Keeping the policy healthy
Once enforced, keep the reporting directive. It is the only way you will hear that a new marketing tag or a plugin update has started loading from a host the policy does not allow.
- Trial every tightening of the policy as a second, report-only header alongside the enforced one.
- Keep the policy in one place, with a note against each third-party host explaining what uses it.
- Check the report log weekly at first, then monthly, and after every plugin, theme or tag manager change.
- Remove hosts you no longer use. A shorter allow list is a smaller target.
The framework CSP guide covers Spring Boot, Tomcat and Angular, and the missing security headers guide covers the other headers scanners expect.
1. What is Content-Security-Policy-Report-Only?
Content-Security-Policy-Report-Only is a response header that tests a Content Security Policy without enforcing it. The browser checks every resource against the policy, prints a warning in the Console for anything that would be refused and sends a report if you include a reporting directive, but it does not block anything. That makes it the safe way to roll out or tighten a policy on a live site, because you see exactly what would break before any visitor is affected by it.
2. Why is my default-src self policy not working?
Usually default-src self is working, but it blocks more or less than you expected. It does not allow inline scripts or styles, other domains, subdomains or your CDN, which is why sites often break. If your own files are blocked, check that self is in single quotes; if nothing is blocked, check the header is not the report-only version, and that a CDN, cache or nginx location block is not removing it. Also remember that default-src does not cover frame-ancestors, base-uri or form-action.
3. Where is the Content Security Policy set?
A Content Security Policy is set in a response header or, less usefully, in a meta tag in the page. The header can come from a CDN rule, the web server configuration, a control panel setting such as Plesk directives, the application code or a CMS plugin. To find it, compare curl output for the CDN address and the origin server, search the server configuration files, and check plugins. If the header disappears when you request the origin directly, the CDN is the layer adding it.
4. Does report-only mode protect my site?
No, report-only mode does not protect your site, because nothing is blocked. An injected script still runs exactly as it would without any policy. It is a measuring tool for building a policy you can enforce. Run it until the reports show only sources you have decided about, then switch to the enforcing header. You can keep a report-only header alongside the enforcing one afterwards, to trial stricter rules without risking visitors.
5. Can I use report-only in a meta tag?
No, browsers ignore Content-Security-Policy-Report-Only when it is placed in a meta tag. Report-only mode only works as a real HTTP response header, and the reporting directives report-uri and report-to are also ignored in meta tags. If you cannot change headers on your hosting, look for a control panel option, a CDN header rule or an application setting that can add them, because a meta tag cannot give you a safe trial run with reports.
6. Should I use report-uri or report-to?
Send both for now, because browser support for report-to is not yet universal and report-uri, although deprecated, is still widely supported. Browsers that understand report-to use it and ignore report-uri, while others fall back to report-uri. The report-to directive needs a separate Reporting-Endpoints header that names the endpoint. The two formats differ slightly, so your collecting script should accept both the application/csp-report and the application/reports+json content types and log them in one place.
7. How long should I run CSP in report-only mode?
Run CSP in report-only mode until new kinds of report stop appearing, which on most small sites takes one to two weeks. You need enough traffic to cover rarely visited pages, such as checkout, account and contact forms, plus the different devices and browsers your visitors use. A busy site may see every source within a day or two. Restart the trial for a few days whenever you add a major plugin, theme or third-party tool, so new sources are caught before they are blocked.
Run the security audit on aiwebpageseo.com to see whether your pages send an enforced or report-only Content Security Policy, whether it is duplicated, and which other headers are missing.
Open Security Audit Fixes โ