Skip to content

The publication for web craftspeople Thursday, 20 August 2026

Security

Content Security Policy: building one that holds

A well-built Content Security Policy blocks injected scripts from running without breaking the site, provided it is rolled out in stages. This guide covers the directives that matter, the observation-before-enforcement method and the pitfalls of third-party-heavy sites.

A well-built Content Security Policy turns most script-injection flaws into mere log lines. The difficulty is deploying one without breaking the site, which calls for a staged method rather than a header copied from a generic example. This guide covers the directives that matter, how to run them in observation mode before making them block, and the pitfalls specific to sites loaded with third-party scripts.

What a CSP actually does

The Content Security Policy is an HTTP header through which a server tells the browser which content sources are legitimate for a page. The browser then enforces that list: a script loaded from an unauthorised domain, an inline event handler injected by an attack, or a network call to a hostile server are all blocked before execution.

The main benefit concerns script-injection attacks, commonly known as XSS. Even when an attacker manages to insert a <script> tag into a page, a strict policy prevents it from running because it matches no declared source. A CSP also covers protection against being embedded in a third-party iframe, through the frame-ancestors directive, which supersedes the old X-Frame-Options header.

Browser enforces the CSP Declared source (self, trusted CDN) allowed Injected inline script, unknown domain: blocked + reported Reporting endpoint report-to

The directives that give a policy its shape

A policy reads as a series of directives separated by semicolons, each naming a resource type and its allowed sources. The default-src directive acts as a safety net: it applies to any type not covered by a more specific directive.

DirectiveControlsSensible starting value
default-srcSafety net for unspecified types'self'
script-srcOrigin of JavaScript'self' + nonce
style-srcOrigin of stylesheets'self'
img-srcOrigin of images'self' data:
connect-srcTargets of fetch, XHR and WebSocket calls'self' + business API
frame-ancestorsWho may embed the page in an iframe'self' or 'none'
base-uriAllowed values for the base tag'self'
form-actionWhere forms may be submitted'self'
object-srcLegacy plugins (Flash, applets)'none'

Three keywords come up constantly. The 'self' value allows the same origin as the page. The 'none' value forbids every source. Conversely, 'unsafe-inline' and 'unsafe-eval' reopen the door to inline scripting and dynamic evaluation, and strip the policy of much of its value as soon as they touch script-src.

The modern approach: nonces and strict-dynamic

Allowing a list of trusted domains in script-src works, but that approach ages badly: every new third-party service lengthens the list, and a single compromised allowed domain is enough to defeat the policy. Today’s recommended approach relies on nonces — random tokens generated per response and attached to legitimate scripts.

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

On the page side, every trusted script carries the matching attribute, with a token regenerated on each request and never reused.

<script nonce="r4nd0m2026" src="/assets/app.js"></script>

The 'strict-dynamic' keyword completes the setup: a script already trusted by nonce may in turn load further scripts without every dependency having to be listed. The policy then focuses on the root of trust rather than on a domain list that drifts over time.

An allowlist of domains grows with every integration; a nonce trusts only the code the server actually emitted.

Deploying without breaking the site

The risk with a CSP is not the vulnerability, it is the regression: one directive that is slightly too strict makes a map, a payment form or an analytics tag disappear. The remedy is a separate header, Content-Security-Policy-Report-Only, which applies exactly the same rules but blocks nothing. The browser simply reports what it would have refused.

A three-step method. Publish the policy in Report-Only first and collect violations across several days of real traffic. Then fix the legitimate sources that surface (widgets, fonts, APIs). Finally switch to the enforcing header once the report stream has gone quiet.

Reports are collected through the report-to directive, backed by the Reporting API, which sends a JSON document to an endpoint on every violation. For broader compatibility with older browsers, the legacy report-uri directive is often kept alongside it during the transition.

Pitfalls on sites heavy with third parties

A brochure site served by a server you control lends itself well to nonces. A site built on an extensible CMS raises a different problem: many plugins inject their own inline <script> tags, without a nonce, at render time. On WordPress, for instance, the temptation to restore 'unsafe-inline' to silence the errors amounts to disabling protection at the single most sensitive point.

Three habits limit the damage. First, inventory the third-party scripts actually in use and drop the ones that no longer serve a purpose. Second, favour extensions that let you attach a nonce, or move inline scripts into files served from the same origin. Third, treat connect-src with as much care as script-src: it is the directive that prevents data exfiltration to a third-party server, usually the end goal of a successful injection.

Testing and maintaining the policy over time

A CSP is never finished: every new feature, marketing integration or plugin update can introduce a legitimate source the policy knows nothing about. Without maintenance discipline, two drifts loom. The first sees the allowlist swell under urgent requests until it permits so much that protection becomes meaningless. The second sees the policy silently block a feature nobody tests, until a user reports the breakage.

The browser console remains the first diagnostic tool: every violation appears there with the offending directive and the refused resource, which is enough to spot a regression during development. For a more systematic assessment, online analysers grade a policy and flag the keywords that weaken it — 'unsafe-inline' and overly broad wildcards first among them. Wiring that check into continuous integration keeps a policy from degrading unnoticed.

Maintenance benefits from a few simple rules: document why each source is allowed, review the policy whenever a third-party service is added, and keep the reporting endpoint live even after switching to enforcement. Violations that keep arriving in production signal either an attack under way or a legitimate feature forgotten during hardening. Either way, the information deserves to be seen.

Key takeaways

  • A CSP neutralises the execution of injected scripts, provided 'unsafe-inline' stays out of script-src.
  • Nonces paired with 'strict-dynamic' are a clear improvement on domain allowlists.
  • Deployment goes through Report-Only first, then enforcement once false positives are handled.
  • frame-ancestors, base-uri, form-action and object-src 'none' extend protection beyond scripts alone.

The first time I rolled out a CSP straight into enforcing mode, I broke a client’s checkout flow in the middle of the day. Since then I never start anywhere but Report-Only, and I let it run a full week before tightening. On WordPress sites loaded with plugins, I consider a half-strict CSP that is actually in place worth more than a perfect policy on paper that nobody dares switch on. — Simon Janvier

Further reading

Reference for the directives and their support: Content-Security-Policy on MDN.

Also on Mail Studio

Read next