If you’ve ever run a website through a security scanner, you’ve probably seen a warning like “Content-Security-Policy header not set.” It sounds alarming and technical, and it’s tempting to either ignore it or paste in a policy from a blog post and hope for the best.
Neither works well. Ignore it, and you’re missing one of the most effective defenses a browser offers. Copy someone else’s policy, and you’ll likely break your contact form, your analytics, or your payment button. Or you’ll quietly block a script you depend on and not notice for weeks, which is something we did on our own site.
This guide explains CSP in plain language and shows you how to roll it out safely.
What a Content Security Policy Actually Does
A Content Security Policy (CSP) is an HTTP header your server sends with each page. It gives the browser an allowlist: these are the places this page is allowed to load scripts, styles, images, fonts, and frames from. Anything not on the list is blocked.
Why does that matter? The most common serious attack on websites is getting malicious JavaScript to run on a page. That can happen through a vulnerable plugin, a compromised third-party script, or a form that echoes user input without cleaning it. Once that script runs, it can read whatever the visitor types, including passwords and card numbers, and send it anywhere.
A good CSP turns that from “game over” into “blocked, with an error in the console.” Even if an attacker gets code onto your page, the browser refuses to run it or refuses to send data to their server.
The Directives You’ll Actually Use
A policy is a list of directives, separated by semicolons. Each directive covers one kind of resource. Here’s a realistic example for a small business site:
default-src 'self';
script-src 'self' https://www.googletagmanager.com https://challenges.cloudflare.com;
style-src 'self' https://fonts.googleapis.com;
font-src 'self' https://fonts.gstatic.com;
img-src 'self' data: https:;
connect-src 'self' https://www.google-analytics.com;
frame-src https://challenges.cloudflare.com https://www.youtube-nocookie.com;
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'self';
upgrade-insecure-requests
(In a real header this is all on one line.) Here’s what each part means:
| Directive | Controls | Plain-English meaning |
|---|---|---|
default-src |
Fallback for everything | “Unless I say otherwise, only load things from my own domain.” |
script-src |
JavaScript | The most important one. Which domains may run code on your page. |
style-src |
CSS | Where stylesheets can come from. |
font-src |
Web fonts | Google Fonts needs fonts.gstatic.com here. |
img-src |
Images | Often loosened to https: because images are low-risk. |
connect-src |
fetch, XHR, WebSockets |
Where page scripts can send data. This is what stops stolen data from being sent out. |
frame-src |
Embedded iframes | YouTube, maps, booking widgets, CAPTCHA challenges. |
object-src |
Old plugins (Flash, etc.) | Set to 'none'. There’s no modern reason to allow it. |
base-uri |
The <base> tag |
Stops an attacker from redirecting all your relative links. |
form-action |
Where forms can submit | Stops an injected form from posting to an attacker’s server. |
frame-ancestors |
Who can embed your site | Protects against clickjacking. Replaces the old X-Frame-Options. |
'self' means “the same origin as this page.” Quotes matter: 'self', 'none', and 'unsafe-inline' are keywords and must be quoted; domains are not.
Step 1: Inventory What Your Site Loads
You can’t write an allowlist without knowing what’s on the list. Open your site in Chrome, open DevTools (F12), go to the Network tab, and reload. Sort by Domain. Do this on several different page types: your home page, a blog post, your contact page, checkout if you have one. Different pages often load different things.
Write down every domain that isn’t yours. For a typical small business site, the list usually includes:
- Analytics (Google Analytics / Tag Manager)
- Fonts (Google Fonts)
- A spam challenge on forms (reCAPTCHA, Cloudflare Turnstile, hCaptcha)
- Embedded video or maps
- A chat or booking widget
- Payment scripts (Stripe, PayPal)
- Ad networks, if you run ads
Ad networks and tag managers are the hardest part. They load scripts that load other scripts from domains you’ve never heard of. Check the vendor’s documentation, since most large vendors publish the domains their scripts need.
Step 2: Deploy in Report-Only Mode
This is the step most guides skip, and it’s the one that saves you. Instead of Content-Security-Policy, send:
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self' ...
In report-only mode, the browser blocks nothing. It just logs a warning to the console (and optionally sends a report to a URL you specify) every time something would have been blocked. Browse your own site with DevTools open and read the Console. Every violation names the directive and the blocked URL, so you know exactly what to add.
Leave it in report-only mode for a week or two while real visitors use the site. Then switch the header name to Content-Security-Policy to start enforcing.
Step 3: Set the Header Where Your Site Is Hosted
Where you put the header depends on your host:
- Vercel: a
headersarray invercel.json, matched by path. - Netlify: a
_headersfile ornetlify.toml. - Cloudflare (in front of any host): a Response Header Transform Rule in the dashboard.
- Apache:
Header set Content-Security-Policy "..."in.htaccessor the server config. - Nginx:
add_header Content-Security-Policy "..." always; - WordPress on shared hosting: usually
.htaccess, or a security plugin that manages headers.
Watch out for one trap: make sure you’re editing the file your host actually reads. Our marketing site contains a _headers file from a period when we hosted on a different platform. It looks authoritative and has a full CSP in it, but our current host ignores it entirely. Edits to it do nothing. If you’ve ever migrated hosts, check for leftovers like this, and verify changes by reading the live response headers (DevTools → Network → click the page request → Response Headers), not by reading your config.
The Mistake We Made: Silent Blocking
When CSP blocks something, nothing visible breaks. There’s no error page or alert. The blocked script simply never runs, and the only evidence is a red line in the browser console that nobody is looking at.
That’s exactly what makes it easy to get wrong in the other direction. During a performance cleanup earlier this year, we removed our ad network’s domains from our CSP as part of taking ads off our sales pages. When we later experimented with putting ads back on blog articles, adding the script tag alone wouldn’t have been enough. Without the matching CSP entries, the browser would have silently refused to load it. (In the end we dropped ads from the site entirely, for reasons we explain in our third-party script audit.)
The lesson: CSP and the scripts it governs need to change together. Whenever you add or remove a third-party tool, update the policy in the same change, and check the console on a live page afterward.
Pro Tip
Keep a comment next to every domain in your policy (in your config file, since headers themselves can’t have comments) noting which tool needs it and which pages use it. Six months from now, nobody will remember why tpc.googlesyndication.com is there, and an unexplained domain is one nobody dares remove.
About ‘unsafe-inline’ and ‘unsafe-eval’
You’ll see these two keywords in many real-world policies, including ours. They exist because a lot of existing code relies on inline <script> blocks and eval():
'unsafe-inline'allows<script>tags with code directly in the HTML, andonclick="..."attributes.'unsafe-eval'allows code that turns strings into executable code at runtime.
Both weaken CSP significantly, because injected attacker code is usually inline too. But a policy with 'unsafe-inline' still restricts which external domains can load scripts and where data can be sent, which is meaningful protection.
The path forward is to move inline scripts into separate .js files over time. For the ones you can’t move, use a nonce (a random value generated on each request and added to both the header and the <script> tag) or a hash of the script’s contents. Static sites can’t easily generate per-request nonces, so hashes are usually the better fit there.
A Sensible Rollout Plan
- Week 1: Inventory third-party domains on every page type. Write a report-only policy.
- Weeks 2–3: Watch the console and reports. Add what’s legitimately needed. Question anything you don’t recognize.
- Week 4: Switch to enforcing. Re-check forms, payments, analytics, and embeds on the live site.
- Ongoing: Treat the policy as part of every change that adds or removes a third-party tool.
- Later: Chip away at
'unsafe-inline'by moving scripts into files.
A Content Security Policy isn’t a one-time checkbox. It’s a living list of what your site trusts, and keeping that list short and accurate is good for security, performance, and your own understanding of what’s actually running on your pages.
❓ Frequently Asked Questions
Can a Content Security Policy break my website?
Yes. That’s why you should start with the Content-Security-Policy-Report-Only header, which reports violations without blocking anything. Only switch to the enforcing header once a week or two of normal traffic produces no unexpected reports.
Can I set a CSP with a meta tag instead of a header?
Partly. A <meta http-equiv="Content-Security-Policy"> tag supports most directives, but not frame-ancestors, report-uri/report-to, or sandbox. A real HTTP header is better whenever your host lets you set one.
Is 'unsafe-inline' really that bad?
It removes most of the protection CSP gives you against injected scripts, because an attacker’s injected <script> is also inline. Many sites start with it and remove it over time by moving inline code into files or using nonces or hashes. It’s a reasonable step on the way, not a place to stop.
Not Sure What Your Site Is Loading?
We set up and maintain security headers as part of every site we build, and we document why each domain is allowed.
Was this article helpful?
WebGlo