How to fix a missing Content-Security-Policy header

OWASP A03 SECURITY HEADERS Updated 17 September 2026 · 8 min read

Short answer

Start in report-only mode, collect violations for a week, then enforce. Never ship unsafe-inline on script-src — use a nonce instead. A CSP you rolled out blindly will break your site; a CSP you rolled out in report-only mode almost never does.

If a scanner told you your site is missing a Content-Security-Policy header, it's telling you that a single injected <script> tag anywhere on your page — from a comment field, a URL parameter, a compromised third-party widget — will execute with full access to your users' sessions. CSP is the browser-level control that stops that.

It is also the security header people most often get wrong, because a careless CSP breaks production immediately and the usual reaction is to weaken it until the errors stop. That's how you end up with script-src 'unsafe-inline' 'unsafe-eval' *, which is a CSP in name only.

What CSP actually does

A CSP is an allowlist. It tells the browser which origins are permitted to supply each type of resource. Anything not on the list is blocked before it runs. The key insight is that XSS works by getting the browser to treat attacker data as code — CSP breaks that by making the browser refuse to run code from anywhere you didn't nominate.

It is a second line of defence, not a first. You still need to escape output properly. CSP is what saves you on the day your escaping has a gap.

Step 1 — Deploy in report-only mode first

This is the step people skip, and it is the step that makes the whole process safe. Content-Security-Policy-Report-Only tells the browser to evaluate your policy and report what would have been blocked, without blocking anything.

Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; report-uri /csp-report

Leave that running for about a week. Real traffic will surface the resources you forgot: the analytics snippet, the embedded map, the font CDN, the chat widget someone added in 2023. Collect them, then build your real policy around what you actually use.

Watch out: report-only mode produces a lot of noise from browser extensions injecting scripts into your pages. Those violations are not your problem — filter reports by whether the blocked URI belongs to a scheme like chrome-extension: before acting on them.

Step 2 — Write the policy

A reasonable starting point for a typical site:

Content-Security-Policy:
  default-src 'self';
  script-src 'self';
  style-src 'self';
  img-src 'self' data: https:;
  font-src 'self';
  connect-src 'self' https://api.yourdomain.com;
  frame-ancestors 'self';
  base-uri 'self';
  form-action 'self';
  object-src 'none';
  upgrade-insecure-requests

Four of those directives do disproportionate work and are often forgotten:

Step 3 — Handle inline scripts properly

Most sites fail here. You have inline <script> blocks, so you add 'unsafe-inline', and your CSP now provides approximately no XSS protection — because the whole attack is injecting inline script.

The correct fix is a nonce: a random value generated per response, put on the header and on every legitimate inline script tag. An injected script won't have it.

# Nginx — generate a per-request nonce
set $cspnonce $request_id;
add_header Content-Security-Policy
  "script-src 'self' 'nonce-$cspnonce'" always;
<!-- and in your template -->
<script nonce="{{ cspNonce }}">
  // this runs
</script>

<script>
  // an injected tag has no nonce — the browser refuses it
</script>

The nonce must be different on every response and must be unpredictable. Reusing a static "nonce" across requests is the same as having no nonce at all.

Inline styles are a softer problem. style-src 'unsafe-inline' is a real but much smaller risk than its script equivalent, and removing every inline style="" attribute from a mature codebase is often not worth the effort. Accept it knowingly if you must, but never accept it on script-src.

Step 4 — Enforce

Once report-only is quiet, change the header name from Content-Security-Policy-Report-Only to Content-Security-Policy. Keep the reporting endpoint — you want to know when something breaks in production.

Apache

<IfModule mod_headers.c>
  Header always set Content-Security-Policy "default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'"
</IfModule>

Nginx

add_header Content-Security-Policy "default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" always;

The always keyword in Nginx matters — without it the header is omitted on error responses, and your 404 and 500 pages are exactly where reflected XSS likes to live.

Common mistakes

MistakeWhy it's a problem
script-src 'unsafe-inline'Defeats the main purpose of CSP. Use a nonce.
default-src *Allowlists the entire internet. Equivalent to no policy.
Only in <meta> tagsframe-ancestors, report-uri and sandbox are ignored in meta. Send a real header.
Missing always in NginxHeader disappears on 4xx/5xx responses.
Static noncePredictable, so an injected script can just include it.

Verifying it works

Load your site, open the browser console, and try to run something the policy should block. You should see an explicit CSP violation message rather than the script executing. Check the header is present on error pages too, not just your homepage.

The header should also be present on every subdomain that serves HTML. A hardened www and a wide-open staging subdomain is a common and avoidable gap.

Check your CSP in about 20 seconds

sitesecure.online runs 51 checks including CSP, HSTS, and the rest of the security header set — then maps every finding to the OWASP Top 10 so you know what actually matters.

Run a free scan →