← Back to Wiki
Web / Security

Add a Content Security Policy Without Breaking Your Own Site

Every security scanner tells you the same thing. You are 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 loaded data now shows an empty box. So you loosen it until the breakage stops, end up permitting everything, and gain nothing. There is no better policy to copy. Spend ten minutes finding out what your site actually loads, and write the policy around that.

Share on X

Why the copy-paste policies fail

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

So a policy borrowed from someone else's blog post is worse than useless. It encodes their site's dependencies, not yours. You get one of two outcomes. A site that is subtly broken in ways you will not notice for weeks. Or a policy so permissive it does nothing.

Step 1: inventory what your site loads

Before you write a single directive, find every external origin your pages pull from. On a static site that 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 it comes back with two or three entries and one pleasant surprise.

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

Here is the trap. Inventory by eyeball is not 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. Miss one and that page breaks while 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
BE WARNED: on a sibling site of mine, exactly one page fetches https://api.github.com at runtime to list the latest release. It is a software download page. Nothing in its <script src> tags hints at that. The markup grep above returns nothing for it. A policy built from that grep alone would have shipped happily, and that one 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. 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 is not fetching a subresource, so no directive applies. Add origins for these and you make the policy weaker for nothing. 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. It is 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"

Write it as one line in your real config. frame-src 'none' is safe here only because the inventory proved there are zero iframes. Embed YouTube and it 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 instead of pasting it repeatedly. The reverse proxy guide covers that layout.

About 'unsafe-inline', honestly

Most guides tell you to use nonces instead. They are right. On a plain static site served by a file server you often cannot. 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. It does blunt the main XSS protection. You still get real value from the rest of the policy:

A partial policy that ships beats 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 the attention. Scanners flag these two as well, 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. It costs nothing and covers older browsers. Permissions-Policy denies APIs your site never uses. If a script ever does get injected, it cannot quietly ask for the camera or location.

Step 6: verify from outside your own network

Test from a machine that is not yours. A cheap VPS, a phone on mobile data, anything off your LAN. Testing from inside hides 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. 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 break the moment you interact with it.

BE WARNED: your reload may not be doing anything. Run Caddy with admin off and caddy reload cannot work at all. There is no admin API for it to talk to. 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 a milder version of the same trap. Run nginx -t or apachectl configtest before reloading, or you will debug 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 add scripts that were never in your source. Rocket Loader, Web Analytics, email obfuscation. A strict script-src blocks 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 injected and you can ignore this. If it is not 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 get reported to the console instead. Leave it for a week of real traffic. Collect what it flags. Fold that into the policy. Only then switch to the enforcing header. Slower, and it cannot break anything while you learn.

When this isn't your problem

Do this one carefully, because a broken CSP fails silently and asymmetrically. It will not take your site down in a way you would notice. It breaks one form, one embed, one page that loads data, and looks completely fine everywhere else. Ten minutes of grep up front is what stops you hearing about it from a user weeks later.