Skip to content
Engineering Square
Security

A Content-Security-Policy that isn't decorative

How we ship a strict, nonce-based CSP that actually stops injected scripts — deployed Report-Only first, enforced second — instead of a host allowlist that looks secure and blocks nothing.

Published July 7, 2026Read 7 minBy Engineering Square

Most Content-Security-Policy headers in production are decorative: a long host allowlist with 'unsafe-inline' quietly left in, which means an attacker who lands a script tag on your page still runs it. A CSP only stops cross-site scripting if it refuses inline script it can't prove you authored — and the way you prove authorship is a per-response nonce or a hash, not a domain on a list. We ship the strict version: every legitimate script and style carries a random nonce minted fresh on each server render, the policy trusts that nonce and nothing else, and 'unsafe-inline' never appears. An injected <script> with no matching nonce doesn't execute. That single property is the entire point of the header, and it's the property a host allowlist throws away the moment it adds 'unsafe-inline' to make the site work.

Getting there without taking the site down is a two-step deploy — Report-Only first, enforce second — and a discipline about what the policy is allowed to trust. Here's the shape we run.

Nonces and hashes, never host allowlists

Google's own security team has been blunt about this for years: host-allowlist CSPs are bypassable in the large majority of real deployments, because somewhere on an allowed domain there's a JSONP endpoint or an old library that turns 'trusted origin' into 'arbitrary script.' The fix is to stop trusting origins and start trusting cryptographic markers you control:

  • SSR pages get a nonce: the server generates a random value per response, stamps it on every first-party <script> and <style>, and emits script-src 'nonce-…' 'strict-dynamic'. Scripts your trusted code loads inherit trust; anything injected does not.
  • Static pages get hashes: with no server render to mint a nonce, we compute the SHA-256 of each inline script and list script-src 'sha256-…'. The content is pinned; change a byte and it stops executing until you re-hash it.
  • Third-party CDN scripts carry Subresource Integrity on top — integrity='sha384-…' crossorigin='anonymous' — so even an allowed origin can't serve you altered bytes.

'strict-dynamic' is the piece that makes nonces livable: it lets a nonce'd loader script pull in the bundles it needs without you enumerating every one, while still refusing anything an attacker injects into the DOM. You trust your entry point, and your entry point vouches for the rest.

Deploy Report-Only first, always

A strict CSP flipped straight to enforce is how you white-screen a production site — one un-nonced inline handler or one forgotten analytics tag and the page stops working. So the policy ships in two stages, and the first stage breaks nothing:

  1. 01Content-Security-Policy-Report-Only with a report-to endpoint. The browser evaluates the exact policy you intend to enforce, blocks nothing, and posts a violation report for every resource that would have been refused.
  2. 02Watch the reports across real traffic for a few days — every browser, every page, the long tail of embeds and extensions. Each violation is either a resource to nonce properly or a genuine thing to cut.
  3. 03When the report stream goes quiet, rename the header to Content-Security-Policy. The policy you enforce is now byte-for-byte the one you already proved safe in the wild.
Report-Only is the restore test of security headers: a policy you've never watched under real traffic is a hypothesis, and production is a bad place to discover the exception you missed.

The header set the CSP sits inside

CSP does the heavy lifting against injection, but it rides with a fixed set of companions we set on every response in Caddy — the reverse proxy is the one place they're guaranteed to apply to static assets, API routes, and rendered pages alike:

  • Strict-Transport-Security: max-age=63072000; includeSubDomains; preload — two years, subdomains included, preload-list eligible, so downgrade attacks have nothing to grab.
  • X-Content-Type-Options: nosniff and X-Frame-Options: SAMEORIGIN — no MIME confusion, no clickjacking in a hostile iframe.
  • Referrer-Policy: strict-origin-when-cross-origin and a Permissions-Policy that denies camera, microphone, and geolocation outright — least privilege for APIs the site never uses.
  • Cross-Origin-Opener-Policy and Cross-Origin-Resource-Policy set to same-origin, and session cookies always Secure; HttpOnly; SameSite=Lax.

None of these are exotic, and that's the point: they're a checklist you close once and verify with a curl of the production response, not a research project. The CSP is where the judgment lives — everything around it is table stakes you either have or you don't.

How we keep it from rotting

A CSP's failure mode isn't a dramatic breach; it's quiet erosion. Someone adds a widget, it needs an inline script, and the fastest fix is to loosen the policy 'just this once.' Do that twice and you're back to a decorative header. So the policy is code-reviewed like code, the Report-Only endpoint stays wired in production to catch drift after every deploy, and the launch checklist refuses to close 'CSP enforced' on 'we added it to Caddy' — it closes on a curl showing the enforcing header and a clean report stream. Strict is only strict if it stays strict, and staying strict is an operational habit, not a one-time configuration.

Have a project like this?Book a working session
Newsletter

Field notes from the bench

Occasional writing on how we build — analytics, launch discipline, AI engineering. No fluff.

No tracking · Unsubscribe anytime · ~Monthly