InsightsSecurity

Content-Security-Policy: what it protects, how to write one, and the mistake that makes it useless

A Content-Security-Policy tells the browser which scripts, styles and connections to trust — it's your primary defense against XSS. A generated app ships with none. Here's how to write one that's strong enough to matter and test it without breaking anything.

Aug 8, 2026 · 7 min read

The short version

A Content-Security-Policy is an HTTP header that tells the browser which sources of scripts, styles, images and connections to trust. Without one, an attacker who manages to inject a <script> tag into your page can run arbitrary code in your users' browsers. With one, the browser refuses to execute anything that isn't explicitly allowed. It's the single most important security header — and almost no generated app sets one.

What a strong CSP looks like

HTTP response headertxt
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data:; font-src 'self'; connect-src 'self' https://api.yourapp.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self';

The key directives

  • `default-src 'self'` — The fallback for every directive you don't set. Only allow resources from your own domain unless a more specific directive overrides it.
  • `script-src` — Which JavaScript sources to trust. 'self' means only scripts from your own domain. Adding 'unsafe-inline' allows inline <script> tags, which defeats most of the protection — this is the difference between a CSP that works and one that's just for show.
  • `style-src` — Which CSS sources to trust. 'unsafe-inline' is often unavoidable because many frameworks inject inline styles, but it should be scoped with a nonce or hash if possible.
  • `img-src` — Where images can load from. https: alone allows any HTTPS image; adding data: allows inline base64 images.
  • `connect-src` — Where fetch/XHR/WebSocket connections can go. List your API host specifically rather than opening it to *.
  • `frame-ancestors 'none'` — Prevents your page from being embedded in an iframe on another site. This is the modern replacement for X-Frame-Options.
  • `base-uri 'self'` — Prevents an attacker from injecting a <base> tag that rewrites all relative URLs to their server.
  • `form-action 'self'` — Prevents form submissions from being redirected to an attacker's server.

The unsafe-inline trap

The most common CSP in a generated app looks like this:

txt
Content-Security-Policy: default-src 'self' 'unsafe-inline' 'unsafe-eval'

This allows inline scripts and eval(), which are the two main vectors for XSS. The CSP is present, so a checkbox in a security scanner turns green — but it protects against nothing. A useful CSP eliminates 'unsafe-inline' and 'unsafe-eval' from script-src, replacing inline scripts with external files or nonce-based allowlisting.

How to ship one without breaking your app

  1. 1

    Start in report-only mode

    Change the header name to Content-Security-Policy-Report-Only. The browser reports violations to the console and to a reporting endpoint, but it doesn't block anything. This lets you find every legitimate resource that needs to be allowed before enforcement begins.

  2. 2

    Collect violation reports for a week

    Monitor the browser console on every page and every user flow. Each violation tells you exactly which directive is too strict. Add the legitimate sources to the allowlist.

  3. 3

    Flip to enforcement once the reports are clean

    Change the header back to Content-Security-Policy. The browser now blocks anything not in the allowlist. Keep the reporting endpoint active so you catch new third-party scripts as they're added.

How Actuant grades it

Actuant reads the CSP header from your deployed app's response and grades it beyond presence:

  • Absent — fail. No CSP at all.
  • Present but allows `unsafe-inline` and `unsafe-eval` — partial. The header exists but provides minimal protection.
  • Present, no `unsafe-inline`/`unsafe-eval`, but `script-src` is too broad (e.g. `https:`) — partial. Better than nothing, but allows scripts from any HTTPS origin.
  • Present, `script-src` is scoped to specific origins, `frame-ancestors` is set, `base-uri` and `form-action` are set — pass.