NEXHUB
Application Security

A Content-Security-Policy That Actually Works

7 min read

In short

The Content-Security-Policy header tells the browser which sources of script, style, image and media a page is allowed to load. The current recommended configuration is a per-request nonce on script-src combined with 'strict-dynamic', plus a restrictive default-src. Host allowlists are no longer considered effective and are bypassable on most real sites.

Here is a policy we see constantly. It is on the site, it shows up green in whatever header-checking tool the client ran, and it restricts absolutely nothing that matters.

Content-Security-Policy: frame-ancestors 'none'; base-uri 'self';
                         object-src 'none'; form-action 'self'

Read it again. There is no script-src. There is no default-src for script-src to fall back to. Script loading is completely unrestricted. The page is protected against being framed and against having its form targets rewritten, which is worth having, but if an injection bug ever appears in this application the policy will not slow it down for a millisecond.

This is the most common Content-Security-Policy failure mode. Not a missing header — a header that looks like a policy and behaves like a decoration.

What the header actually does

Content-Security-Policy is a browser-enforced allowlist declaring where a page may load resources from. The browser reads the header, and refuses anything the policy does not permit. It is defined in the W3C's Content Security Policy Level 3 specification.

The critical thing to understand is that it is a containment control, not a fix. It does not stop an injection bug from existing. It stops that bug from doing anything useful once it does. That distinction matters when you are deciding how urgently to deploy one: it is not an emergency until the day it suddenly is.

Why host allowlists stopped working

The original approach was to list the domains you trust:

script-src 'self' https://cdn.example.com https://www.google-analytics.com

This looks reasonable and fails in practice for two reasons.

First, big CDNs host everything. If your allowlist includes a general-purpose CDN, an attacker who can inject a <script src> tag can very often find something on that same CDN — an old AngularJS build, a JSONP endpoint — that lets them execute arbitrary code. Your allowlist said yes to the domain, not to the file.

Second, allowlists rot. Someone adds a tag manager under deadline, the policy gets widened to accommodate it, and nobody ever narrows it again. Google's own research into deployed policies found the large majority of real-world allowlist policies were bypassable.

The pattern that replaced it: nonce plus strict-dynamic

A nonce is a random value generated fresh for every single response. The server puts it in the header and on each script tag it intends to allow. The browser runs only the scripts carrying the matching value.

Content-Security-Policy: script-src 'nonce-e10f01f2b15e404bb2b6f55d59fdb2f5' 'strict-dynamic'
<script nonce="e10f01f2b15e404bb2b6f55d59fdb2f5" src="/app.js"></script>

An attacker injecting a script tag cannot guess the nonce, so their tag does not run. Two rules govern this and both are absolute: the value must be cryptographically random, and it must never be reused across responses. A nonce baked into a cached static page is not a nonce, it is a password everybody knows.

'strict-dynamic' handles the awkward part. Modern applications load scripts from scripts — a bundler loads a chunk, which loads another chunk. Those child scripts have no nonce. 'strict-dynamic' says: any script loaded by an already-trusted script inherits that trust. Without it you would be adding nonces to code you do not control.

The keywords that look like mistakes and are not

A correctly built strict policy contains lines that will make a reviewer flinch:

script-src 'self' 'nonce-{RANDOM}' 'strict-dynamic'
           'wasm-unsafe-eval' https: 'unsafe-inline';

'unsafe-inline' and https: sitting in a script-src is normally a red flag. Here they are inert. A browser that understands 'strict-dynamic' ignores both host allowlists and 'unsafe-inline' whenever a nonce is present. They exist purely so that an older browser, which does not understand 'strict-dynamic', still gets a working page instead of a blank one. This is the documented fallback pattern in Google's strict CSP guidance.

'wasm-unsafe-eval' is narrower than it sounds. It permits WebAssembly compilation without permitting eval() on JavaScript. If part of your media pipeline needs WASM, this is the correct keyword to reach for rather than the much broader 'unsafe-eval'.

Deploy in report-only mode first. Always.

There is a second header, Content-Security-Policy-Report-Only, which uses identical syntax and enforces nothing. The browser evaluates the policy, logs what it would have blocked, and lets the page work normally.

Ship the report-only version first. Leave it up long enough to cover the pages that only get visited occasionally. Then switch.

One practical warning about the switch: once enforcing, remove the report-only header. If both are present the browser honours both independently, and you will spend an afternoon debugging violations from a policy you thought you had replaced.

The things that break, in the order they break

From experience, a first strict policy usually trips over the same handful of things.

What breaksWhyUsual fix
Video playersBlob URLs for media segments and worker threadsmedia-src blob: and worker-src 'self' blob:
PDF viewersOften compile WebAssembly and render to a blob'wasm-unsafe-eval', plus blob in object-src or frame-src
Analytics and tag managersInject further scripts at runtime'strict-dynamic' normally covers this
Framework inline stylesStyles emitted at runtime cannot carry a noncestyle-src 'unsafe-inline' — see below
Fonts and data URIsdata: is not permitted by defaultfont-src 'self' data:

Test the video and document paths specifically before enforcing. They are the ones that fail silently in a way nobody notices until a customer complains.

The style-src compromise nobody likes

Most component frameworks emit inline styles at runtime, and those styles cannot carry a nonce without a build-level change. So a great many real policies end up here:

style-src 'self' 'unsafe-inline';

This one is genuinely permissive, unlike the script-src keywords above. It is worth being straight about that rather than pretending otherwise. The residual exposure is style-based injection — defacement, or clickjacking-adjacent tricks — which cannot execute script under this policy. Most teams accept it. The right move is to record it as an accepted residual with a reason, not to leave it undocumented and hope nobody asks.

Add a reporting endpoint

A policy with no report-uri or report-to directive is enforcing blind. You will find out it broke something when a user tells you, which is the worst available feedback loop.

With reporting configured, the browser posts a JSON report on every violation. That turns your next policy change from a spot check into a measurement. It is the single highest-value addition to a policy that is otherwise already correct.

A complete working policy

Pulling it together. Adjust the media hosts to your own; everything else is a reasonable default.

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM}' 'strict-dynamic'
             'wasm-unsafe-eval' https: 'unsafe-inline';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: blob: https://cdn.example.com;
  font-src 'self' data:;
  connect-src 'self' https://cdn.example.com;
  media-src 'self' blob: https://cdn.example.com;
  worker-src 'self' blob:;
  frame-ancestors 'none';
  base-uri 'self';
  object-src 'none';
  form-action 'self';
  report-to csp-endpoint

Note that frame-ancestors, base-uri, form-action and object-src are listed explicitly. They are not fetch directives, so default-src does not cover them. Leaving them out is the second most common mistake after leaving out script-src entirely.

Related service

Security & Configuration Audit

Security audit services covering TLS, HTTP security headers, cookies, Content-Security-Policy, HTTP methods, exposed files and error handling, measured against current practice. Free consultation.

See how we run it →
FAQ

Questions,Answered

It tells the browser which sources a page may load scripts, styles, images, fonts and media from, and the browser refuses anything outside that list. It is a containment control: it does not prevent an injection bug, it prevents that bug from loading or running attacker-controlled code once one exists.

Keep reading

Application Security6 min read

The Security Headers Checklist

Ten minutes of configuration closes the layer that every auditor, customer questionnaire and procurement review checks first. Here is the whole list, with values.

Read the article
Application Security7 min read

What Is Penetration Testing?

A penetration test is not a scan with better branding. Here is what actually happens during one, phase by phase, and what the report should contain when it lands.

Read the article
Let's Talk

Ready to GetStarted?

We are happy to offer a free initial consultation to explore your goals and how NEXHUB can help you reach them.

Get a Free Consultation