← Back to Wiki
Web / Security

Add a Content Security Policy Without Breaking Your Own Site

Every security scanner eventually tells you the same thing: you're missing a Content-Security-Policy header. So you search for one, paste in something that looks strict, and your site quietly stops working — a button does nothing, an embed goes blank, a page that used to load data now shows an empty box. So you loosen it until the breakage stops, end up with a policy that permits everything, and gain nothing. The fix isn't a better policy to copy. It's spending ten minutes finding out what your site actually loads, and writing the policy around that.

Share on X

Why the copy-paste policies fail

A CSP is not a security setting you turn on. It's an allowlist of your specific site's behaviour. The browser refuses to load anything the policy doesn't explicitly permit, and it fails silently — no error page, no 500, just a resource that never arrives and a message buried in the console that nobody is looking at.

That's why a policy borrowed from someone else's blog post is worse than useless: it encodes their site's dependencies, not yours. The two most common outcomes are a site that's subtly broken in ways you won't notice for weeks, or a policy so permissive it isn't doing anything.

Step 1: inventory what your site loads

Before writing a single directive, find every external origin your pages pull from. On a static site this is a grep, not a research project:

# every external origin referenced by a resource-loading tag
grep -rhoE '<(script|img|link|video|audio|source|embed|object)[^>]*(src|href|data)="https?://[^"]+' \
  /path/to/site --include=*.html \
  | grep -oE 'https?://[^"]+' \
  | sed -E 's#(https?://[^/]+).*#\1#' | sort -u

Do the same for stylesheets, fonts and iframes. Keep the list — it becomes your policy. On a small site this usually comes back with two or three entries and one pleasant surprise.

Step 2: the one that will catch you — runtime fetch()

Here's the trap, and it's the reason inventory-by-eyeball isn't enough. The grep above finds things referenced in your markup. It will not find a page that calls an API from JavaScript after it loads. Those requests are governed by connect-src, and if you miss one, that page breaks and nothing else does — which makes it maddening to track down later.

# origins called from JavaScript at runtime, which the markup grep cannot see
grep -rn "fetch(" /path/to/site --include=*.html --include=*.js \
  | grep -oE "https?://[^\"'\`) ]+" \
  | sed -E "s#(https?://[^/]+).*#\1#" | sort -u
A real example. On a sibling site of mine, exactly one page — a software download page — fetches https://api.github.com at runtime to list the latest release. Nothing in its <script src> tags hints at that, and the markup grep above returns nothing for it. A policy built from that grep alone would have shipped happily, and that single page would have silently stopped showing downloads while every other page looked perfect.

Grep for XMLHttpRequest, axios, EventSource and WebSocket too if your site uses them. WebSockets need connect-src as well, with the wss:// scheme.

Step 3: don't be fooled by cross-domain metadata

Your grep will turn up cross-domain <link> tags. Most of them are not resource loads and CSP does not govern them:

<link rel="canonical" href="https://example.com/page.html">

Canonical URLs, og:url, hreflang and plain <a href> links are metadata and navigation. The browser isn't fetching a subresource, so no directive applies. Adding origins to your policy for these makes it weaker for no benefit. Check what the tag actually is before you allowlist its host — on this site every single cross-domain <link> turned out to be rel="canonical".

Step 4: write the policy

Now the policy writes itself, because it's just your inventory in header form:

Content-Security-Policy "default-src 'self';
  script-src 'self' 'unsafe-inline' https://analytics.example.com;
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  font-src 'self' data:;
  connect-src 'self' https://analytics.example.com https://api.github.com;
  frame-src 'none';
  object-src 'none';
  base-uri 'self';
  form-action 'self';
  frame-ancestors 'none';
  upgrade-insecure-requests"

Written as one line in your actual config. Note frame-src 'none' — safe here only because the inventory proved there are zero iframes. If you embed YouTube, that has to say so.

Where the line goes depends on your server: a header block in Caddy, add_header in nginx, Header always set in Apache. If several sites share one proxy, define it once as a snippet and import it per site rather than pasting it repeatedly — the reverse proxy guide covers that layout.

About 'unsafe-inline', honestly

Most guides tell you to use nonces instead. They're right, and on a plain static site served by a file server you often can't. A nonce has to be freshly generated per request and injected into both the header and every inline tag — that needs templating. If your site is HTML files on disk, there is no request-time step to do it in.

So if you have inline <script> blocks or style="…" attributes, 'unsafe-inline' is the honest answer until you refactor them out. That does blunt the main XSS protection — but you still get real value from the rest of the policy:

A partial policy that ships is worth more than a perfect one you keep postponing. Get this live, then move the inline scripts into files and drop 'unsafe-inline' as a follow-up.

Step 5: the two headers everyone forgets

CSP gets all the attention, but scanners flag these too and both are one-liners:

X-Frame-Options "DENY"
Permissions-Policy "accelerometer=(), autoplay=(), camera=(), display-capture=(),
  encrypted-media=(), fullscreen=(self), geolocation=(), gyroscope=(), magnetometer=(),
  microphone=(), midi=(), payment=(), usb=(), xr-spatial-tracking=()"

X-Frame-Options is superseded by frame-ancestors, but it costs nothing and covers older browsers. Permissions-Policy denies APIs your site never uses — if a script ever does get injected, it can't quietly ask for the camera or location.

Step 6: verify from outside your own network

Test from a machine that isn't yours — a cheap VPS, a phone on mobile data, anything off your LAN. Testing from inside can hide proxy, DNS and firewall behaviour that changes the answer:

curl -sI https://example.com/ | grep -iE 'content-security|x-frame|permissions-policy'

Then open the site in a browser with DevTools on the Console tab and click through the pages that do something — a form, a page that loads data, anything with an embed. CSP violations appear there and only there. A page that looks fine on a static load can still be broken the moment you interact with it.

Gotcha: your reload may not be doing anything. If you run Caddy with admin off, caddy reload cannot work at all — there's no admin API for it to talk to, and it fails with connection refused while the old config keeps serving. Use systemctl restart caddy, and always run caddy validate first. Nginx and Apache have the same trap in milder form: always nginx -t / apachectl configtest before reloading, or you'll be debugging a policy that was never applied.

If you're behind Cloudflare or another proxy

Check whether the proxy injects its own JavaScript before you lock things down. Cloudflare features like Rocket Loader, Web Analytics and email obfuscation add scripts that were never in your source, and a strict script-src will block them. Fetch the live page and look:

curl -s https://example.com/ | grep -oE '(cdn-cgi/[a-z/]+|cloudflareinsights\.com|rocket-loader)' | sort -u

Empty output means nothing is being injected and you can ignore this. If it isn't empty, add https://static.cloudflareinsights.com to script-src and connect-src. Anything served from a /cdn-cgi/ path on your own domain is already covered by 'self'.

The safer rollout, if your site is big

Everything above assumes you can enumerate a site you control. For a large or unfamiliar codebase, let the browser do the inventory for you — ship the policy in report-only mode first:

Content-Security-Policy-Report-Only "default-src 'self'; …"

Nothing is blocked; violations are reported to the console instead. Leave it for a week of real traffic, collect what it flags, fold that into the policy, and only then switch to the enforcing header. Slower, but it cannot break anything while you learn.

When this isn't your problem

The reason this one is worth doing carefully is that a broken CSP fails silently and asymmetrically. It won't take your site down in a way you'd notice — it'll break one form, one embed, one page that loads data, and look completely fine everywhere else. Ten minutes of grep up front is what stops you finding out from a user weeks later.