WebCheck

Content-Security-Policy: directives, nonces and a safe rollout

A Content-Security-Policy tells the browser where your site may load scripts, images and other content from. A script slipped into the page then has nowhere to run.

Updated: 05 November 2026

What CSP protects against

In a cross-site scripting (XSS) attack, someone gets their own JavaScript into your page, perhaps through an unfiltered form field or a vulnerable plugin. The browser runs it as if it came from you. That is enough to hijack a session or read what visitors type.

A Content-Security-Policy (W3C, CSP Level 3) is an HTTP header listing the sources your page may use. Anything not on the list, the browser blocks. You still have to fix the hole that let the code in. Until you do, the policy stops the injected script from running and from pulling in code from elsewhere.

The frame-ancestors directive also controls who may put your page in a frame. That stops clickjacking, where another site lays your page invisibly over a harmless button and the visitor clicks on your site unawares.

How the header is built

A policy is a list of directives separated by semicolons. Each one names the allowed sources: 'self' for your own origin, hosts such as https://cdn.example.com, or 'none'. Keywords go in single quotes.

Directive Controls
default-src fallback for every resource type without its own rule
script-src JavaScript
style-src CSS
img-src images
connect-src fetch, XHR and WebSocket connections
object-src legacy plugin content (object, embed); set it to 'none'
base-uri permitted values for the base element
form-action where forms may submit to
frame-ancestors who may embed the page

The last three do not fall back to default-src. Leave them out and anything goes.

X-Frame-Options with DENY or SAMEORIGIN is the older header for the same job. Browsers still honour it, but it cannot list several permitted sites. Where both are present, frame-ancestors wins.

What weakens a policy

'unsafe-inline' in script-src allows any script written straight into the HTML. That is exactly how injected code arrives, so the policy loses most of its value. 'unsafe-eval' permits eval() and similar functions that turn text into code. Wildcards such as *, https: or data: in script-src are no better, since an attacker then only needs some server with a certificate.

In style-src, 'unsafe-inline' is a common compromise. The risk there is far lower.

Nonces, hashes and 'strict-dynamic'

You will usually have a few inline scripts of your own. A nonce covers them: a random value the server generates afresh for every response and places both in the header, as 'nonce-…', and in the nonce attribute of each script it trusts. An attacker cannot guess it. A hash ('sha256-…') allows one script with exactly that content and suits static snippets that never change.

Once a directive contains a nonce or a hash, current browsers ignore 'unsafe-inline', which is left in only for very old browsers. 'strict-dynamic' extends trust to scripts that an allowed script loads itself, and host lists and 'self' then stop counting. It helps with tag managers and widgets whose sources keep changing.

Content-Security-Policy: script-src 'nonce-k3Jd9fQ2xW' 'strict-dynamic'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; report-to csp
Reporting-Endpoints: csp="https://example.com/csp-reports"

Rolling it out with Report-Only

If an enforced policy blocks the wrong thing, your checkout may stop working. Content-Security-Policy-Report-Only avoids that. The browser evaluates the policy without blocking anything and logs each violation to the console. Add report-to together with a Reporting-Endpoints header and it also posts the reports to an address you choose. Older browsers only understand report-uri, so many sites send both for now.

A starting point for a site without third-party scripts:

Content-Security-Policy-Report-Only: default-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'

When a few weeks pass without unexpected reports, send the same policy as Content-Security-Policy. A policy left in report-only mode for good protects nobody.

CSP on a WordPress site

The first report-only run on a typical WordPress site produces dozens of violations. Themes and plugins write inline scripts into the page, on top of the tag manager, cookie banner and embedded videos. Work through the reports and add each source you genuinely need. A source nobody recognises deserves a closer look anyway.

Nonces need support from the CMS or a plugin, because every script element must carry the attribute. Header and HTML have to share the same nonce, and it must change with every response. Full-page caches that serve ready-made HTML make that awkward.

If that cannot be done straight away, a policy with specific hosts still beats none at all. object-src 'none', base-uri 'self' and frame-ancestors 'self' rarely break anything and can go on any site today. Before adding form-action, check whether a form posts to an outside service such as a newsletter tool.

Common mistakes

  • frame-ancestors in a meta tag. A CSP can be set in the HTML with meta http-equiv, but browsers ignore frame-ancestors, report-uri and sandbox there. Clickjacking protection only works as a header.
  • self without quotes. The browser reads it as a host name.
  • Two policies, one from the web server and one from a plugin. The browser applies both, so a source has to be allowed in each.
  • Setting default-src 'self' and assuming it covers frame-ancestors and form-action.
  • A fixed nonce that never changes.

Checking CSP

Enter your address at ZwoWebCheck. The check is passive and evaluates only the response headers your server sends to every browser anyway. Your report rates the CSP and clickjacking protection alongside the other security headers, and tells you whether the policy is enforced yet or lets scripts in too freely.