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.
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.
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.
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
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.
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".
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.
'unsafe-inline', honestlyMost 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:
frame-ancestors 'none'. Nobody can frame your site for clickjacking.object-src 'none'. Kills a whole legacy plugin attack class.base-uri 'self'. Stops an injected <base> tag silently
repointing every relative URL on the page.form-action 'self'. An injected form cannot post your users' input
offsite.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.
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.
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.
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.
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'.
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.
'unsafe-inline' is not your ceiling. Most frameworks have a CSP
middleware that does it properly.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.