Content Security Policy for Beginners: Roll Out CSP Safely

Learn Content Security Policy step by step: start in report-only mode, use nonces or hashes, and fix the inline scripts and widgets that usually break.

By the MakeItFlow team Updated 3 min read
Share LinkedIn X Facebook WhatsApp
Content Security Policy for Beginners: Roll Out CSP Safely

Content Security Policy (CSP) is the header that does the most to limit cross-site scripting. It is also the one that breaks sites when someone pastes a strict policy straight into production. Used properly, CSP tells the browser which scripts it may run, so a script an attacker manages to inject simply doesn't execute. This guide takes you from no policy to an enforced one without taking your site down: report-only first, then nonces or hashes, then fixing the things that break.

Key takeaways

  • Start Content Security Policy in report-only mode for at least a week or two, so you see breakages before your visitors do.
  • A strict policy using a random nonce or hash per script, plus strict-dynamic, is what web.dev and MDN recommend.
  • Generate a fresh nonce for every response and never let a CDN cache HTML that contains one.
  • Fix common breakages by moving inline event handlers into nonced scripts and adding embed, font and analytics hosts to the right directives.

How CSP works in one paragraph

You send a Content-Security-Policy response header made of directives, separated by semicolons. Each directive names a resource type and the sources allowed for it:

Content-Security-Policy: default-src 'self'; img-src 'self' https://images.example-cdn.com; object-src 'none'

When a page tries to load something the policy doesn't allow, the browser blocks it and logs a violation in the console. Fetch directives such as script-src and img-src fall back to default-src when they are missing. A few directives don't fall back, notably frame-ancestors, base-uri and form-action, so you have to set those explicitly.

Two styles of policy: allowlist vs strict

Allowlist CSP lists every domain you trust: script-src 'self' https://www.googletagmanager.com https://cdn.example.com .... It's intuitive but fragile. Every new vendor means a policy change, and broad allowlists often include a domain that hosts something an attacker can abuse, such as a JSONP endpoint or an old library.

Strict CSP trusts individual scripts rather than domains, using a random nonce or a hash. This is what Google's web.dev and MDN recommend:

Content-Security-Policy:
  script-src 'nonce-R4nd0mV4lu3' 'strict-dynamic';
  object-src 'none';
  base-uri 'none';
  • 'nonce-...' allows only <script> tags carrying that exact nonce attribute. The nonce must be random and new on every response. web.dev recommends at least 128 bits from a cryptographically secure generator.
  • 'strict-dynamic' lets a trusted script load further scripts, which is how tag managers and widgets work, without you listing their domains.
  • object-src 'none' disables <object> and <embed>. base-uri 'none' stops an injected <base> tag from redirecting your relative script URLs.

For most small sites, the practical path is to start with a loose allowlist in report-only mode and tighten it over time. If you run a server-rendered app you control, go straight for a nonce-based policy.

Step 1: Deploy in report-only mode

Content-Security-Policy-Report-Only takes exactly the same syntax but blocks nothing. The browser only reports what would have been blocked. This is the most important step, because it lets you see breakages before your visitors do.

Reporting-Endpoints: csp-endpoint="https://example.com/csp-reports"
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self' 'nonce-R4nd0mV4lu3' 'strict-dynamic'; object-src 'none'; base-uri 'none'; report-to csp-endpoint; report-uri https://example.com/csp-reports

Notes:

  • report-to is the current mechanism (it uses the Reporting API and the Reporting-Endpoints header). report-uri is deprecated, but MDN recommends sending both until every browser supports report-to.
  • The two use different payload formats: application/reports+json for report-to and application/csp-report for report-uri. Your endpoint should accept both.
  • Report-only cannot be set in a <meta> tag. It has to be an HTTP header.
  • You can send an enforced policy and a report-only policy at the same time. That's useful for testing a stricter version while a looser one is enforced.

Leave report-only running for at least a week or two of normal traffic, long enough to hit checkout flows, logged-in pages and monthly campaigns. Expect noise: browser extensions inject scripts and generate violations you can't fix and can ignore.

Step 2: Add nonces (dynamic sites) or hashes (static pages)

Nonces

Generate a nonce per response and put it in both the header and each legitimate script tag. In PHP:

<?php
$nonce = base64_encode(random_bytes(16)); // 128 bits
header("Content-Security-Policy: script-src 'nonce-$nonce' 'strict-dynamic'; object-src 'none'; base-uri 'none'");
?>
<script nonce="<?= $nonce ?>" src="/js/app.js"></script>
<script nonce="<?= $nonce ?>">
  window.appConfig = { locale: "en" };
</script>

In Node/Express:

import crypto from "node:crypto";

app.use((req, res, next) => {
  res.locals.nonce = crypto.randomBytes(16).toString("base64");
  res.setHeader(
    "Content-Security-Policy",
    `script-src 'nonce-${res.locals.nonce}' 'strict-dynamic'; object-src 'none'; base-uri 'none'`
  );
  next();
});

Two rules matter here. Never reuse a nonce across responses. That includes full-page caching: if a CDN caches the HTML, every visitor gets the same nonce, which defeats it. And only add the nonce to scripts your templates write, never by rewriting every <script> tag on output, or injected scripts get a valid nonce too.

Hashes

For static pages where the HTML is cached, hash each inline script instead. The hash covers the exact content between the tags, whitespace included:

echo -n 'window.appConfig = { locale: "en" };' | openssl dgst -sha256 -binary | openssl base64
Content-Security-Policy: script-src 'sha256-BASE64HASHHERE' 'strict-dynamic'; object-src 'none'; base-uri 'none'

Chrome DevTools also prints the expected hash in the console message when it blocks an inline script, which saves you computing it by hand.

Step 3: Fix the common breakages

Read your reports and console errors and work through them. These are the usual suspects:

Violation Cause Fix
Inline event handler blocked onclick="...", onload="..." in HTML Move to addEventListener in a nonced or external script
javascript: URL blocked <a href="javascript:void(0)"> Use a <button> with a listener
eval blocked Old libraries, new Function(), string setTimeout Upgrade the library; use JSON.parse; 'unsafe-eval' only as a last resort
Inline style blocked style-src set without 'unsafe-inline' Usually acceptable to allow 'unsafe-inline' in style-src at first; script control matters most
YouTube, maps or payment iframe blocked frame-src (or default-src) doesn't include the provider Add the provider's origin to frame-src
Fonts blocked font-src falls back to 'self' Add the font host, for example https://fonts.gstatic.com
Analytics beacons blocked connect-src missing the collector domain Add the collector origin to connect-src

Refactoring an inline handler looks like this:

<!-- Before: blocked by CSP -->
<button onclick="openMenu()">Menu</button>

<!-- After -->
<button id="menu-btn">Menu</button>
<script nonce="R4nd0mV4lu3">
  document.getElementById("menu-btn").addEventListener("click", openMenu);
</script>

Two behaviors surprise people. When a directive contains a nonce or hash, modern browsers ignore 'unsafe-inline' in that directive, so adding it "to make things work" does nothing there. 'unsafe-eval', on the other hand, still applies alongside nonces, and it noticeably weakens the policy.

Step 4: Enforce, then keep watching

When the reports are down to extension noise, rename the header from Content-Security-Policy-Report-Only to Content-Security-Policy. Keep the report-to directive so you hear about new breakages, for example when marketing adds a new tag. Run the final policy through Google's CSP Evaluator or Lighthouse, since a single wildcard can quietly undo the protection.

A sensible enforced policy for a typical marketing site that uses a nonce-aware tag manager:

Content-Security-Policy: script-src 'nonce-{RANDOM}' 'strict-dynamic' https: 'unsafe-inline'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'; form-action 'self'; upgrade-insecure-requests; report-to csp-endpoint

The https: 'unsafe-inline' part is a fallback for very old browsers. Browsers that understand 'strict-dynamic' and nonces ignore both, so it doesn't weaken anything for modern visitors.

Frequently asked questions

Can I set CSP with a meta tag instead of a header?

Partly. <meta http-equiv="Content-Security-Policy"> works for most fetch directives, but it doesn't support frame-ancestors, report-uri or sandbox, and report-only mode can't be delivered by meta at all. Use the HTTP header in production when you can.

Does CSP replace input sanitization?

No. CSP is a second layer that limits damage when an XSS bug slips through. You still need to escape output and validate input. Treat CSP as a seatbelt, not a reason to drive carelessly.

My WordPress site breaks with any strict CSP. What can I do?

Many themes and plugins print inline scripts without nonces. Start with a report-only policy that controls object-src, base-uri, frame-ancestors and form-action, which rarely break anything, and only tighten script-src once you know which plugins print inline code. Our WordPress security checklist covers other hardening steps that don't depend on CSP.

How do I know if my CSP is actually protecting anything?

If script-src (or default-src) contains 'unsafe-inline' without a nonce or hash, or a bare https: or * without 'strict-dynamic', it allows injected scripts. Google's CSP Evaluator highlights these gaps.

What to do next

Start today with a report-only header and a reporting endpoint, even a simple one that logs JSON to a file. Fix the violations over a couple of weeks, then enforce. Run a free audit to confirm the header is being served on every hostname, and see our security headers guide for the rest of the baseline set. If clickjacking is your main concern, the frame-ancestors directive is covered in Clickjacking protection.

Sources

#content security policy #csp #xss #nonces #security headers

More guides for your industry

Free checklist (PDF)

Free web developers website checklist

31 practical checks for web developers: website speed and mobile, Google Business Profile and reviews, email security, booking and enquiry handling, and AI search visibility. Each one says why it matters, and many link to a free tool that checks it.

We email you the PDF and nothing else unless you tick the box. See the privacy policy.

Written by the MakeItFlow team

We build the website audit these guides are based on. Every fact is checked against the primary sources listed above and this guide was last reviewed on 21 October 2026 . How we research and check

Share LinkedIn X Facebook WhatsApp

Check your own site in 30 seconds

Run a free audit covering speed, SEO, DNS, email authentication and security headers — and get a prioritised fix list.

Weekly newsletter

One practical website fix, every week

New guides on speed, SEO, email deliverability and security — straight to your inbox. No spam, unsubscribe in one click.

Keep reading