Content Security Policy
An attacker sneaks a <script> tag into your page, hidden in a comment field nobody cleaned. What happens?
The browser runs it. To the browser, a script is a script. It cannot tell you did not put it there.
Content Security Policy adds a second question. Is this script on the list? If not, the browser refuses to run it. The attacker's code sits in the HTML, inert. That is the idea. The rest is mechanism.
The problem
Here is what goes wrong without CSP.
- 01attacker submits a comment: <script>steal()</script>
- 02server embeds the comment in the HTML
- 03browser parses HTML, sees the script tag
- 04browser runs the script
- 01attacker submits a comment: <script>steal()</script>
- 02server sends the HTML + Content-Security-Policy header
- 03browser parses HTML with the policy in effect
- 04script has no nonce, browser refuses to run it
The two columns differ in one line: the header. Same server, same HTML, same attacker. The header is a list of rules the browser agrees to enforce.
The idea
Every response carries HTTP headers. One of them is the policy:
Content-Security-Policy: script-src 'self'; object-src 'none'Read it out loud. Scripts only from my own site. Object and embed tags, never. The browser keeps the rule for the page and checks every load against it.
Rules exist for images, stylesheets, fetch targets, frame embedders. Each is a category and a list of sources. You write the promise, the browser enforces it.
The handshake
A real page load. The server sends HTML with the header attached.
GET /profile HTTP/1.1
Host: example.com
-----------------------------------------------------
HTTP/1.1 200 OK
Content-Type: text/html
Content-Security-Policy: script-src 'self' 'nonce-r7F2p'; object-src 'none'
<html>
<body>
<script nonce="r7F2p" src="/app.js"></script> <!-- allowed -->
<script src="https://evil.com/x.js"></script> <!-- blocked -->
<script>alert(1)</script> <!-- blocked -->
</body>
</html>Three script tags. One runs. Why?
nonce="r7F2p"The third case is the one that matters: an inline script an attacker slipped into the HTML. The rule is "only scripts with the nonce r7F2p." The attacker's script has no nonce. It stays dead on the page.
Why "a list of CDNs" does not work
Most first CSPs look like this:
script-src 'self' https://cdn.jsdelivr.net https://www.google-analytics.com https://static.intercom.io ...A list of trusted hostnames. It feels right: these are the CDNs your site uses. But every hostname on the list also hosts code written by someone else. Google audited 1.6 million real policies in 2016. 94% of allowlist policies had a bypass.
Here is how one leaks.
Not a jsDelivr problem. A hostname list is only as safe as its weakest host. A single entry that runs strings as code breaks the entire policy, and plenty of CDNs ship such entries. Do not use an allowlist. Mark your own scripts with a secret only you know.
The nonce trick
Your server generates a fresh random string per page. Call it a nonce. It uses the value twice.
An attacker slips in <script>steal()</script>. No nonce, and they cannot guess it: 128 random bits, fresh this second. Their script is blocked. Yours carry the nonce because you wrote it in. Next page, new value. Watch the server pane, or click regenerate.
A hash works too. Instead of matching a random value, the browser matches the SHA-256 of the script body. Nonces fit server-rendered pages. Hashes fit static CDNs. Pick one per script.
strict-dynamic: trust cascades
One problem remains. Your site loads third-party scripts: analytics, chat widgets, error tracking. You cannot nonce them because your own script adds them dynamically. What now?
Add 'strict-dynamic'. It tells the browser: a trusted script can vouch for the scripts it loads.
by the root
by the root
by the root
A trusted script can add another script, and the new one inherits trust. A script already sitting in the HTML (because an attacker put it there) has no parent in the trust tree. Blocked.
Trusted Types: the other door
CSP stops scripts from loading. It does not stop already-running code from doing something bad with user input:
element.innerHTML = userComment // if userComment contains <img onerror=...>, we are cookedNo <script> tag. No external request. A string that the browser parses into HTML with event handlers. This is DOM XSS. CSP misses it because nothing loads.
Trusted Types adds one more rule:
- 01el.innerHTML = userInput
- 02browser parses it as HTML
- 03attacker-supplied code runs
- 01el.innerHTML = userInput
- 02browser throws TypeError
- 03must pass TrustedHTML from a named policy
Turn it on with one line: require-trusted-types-for 'script'. The only full DOM XSS defence the web platform offers. Chrome, Edge, and Firefox support it.
The rollout
Do not enforce on day one. Every policy breaks something. A second header reports without blocking: Content-Security-Policy-Report-Only.
- Week 0
Ship in Report-Only
Deploy the strict-CSP skeleton as Content-Security-Policy-Report-Only. Nothing is blocked, violations are reported.
- Week 1
Route reports
Send violations to Sentry, Report URI, or a self-hosted endpoint. Expect noise on day one: browser extensions, legacy integrations, stray inline scripts.
- Week 2
Fix the noise
Add nonces to real inline scripts, remove inline event handlers (onclick=...), rewrite third-party loaders that fight strict-dynamic.
- Week 4
Promote to enforcement
When the report stream is quiet for a week, rename the header to Content-Security-Policy. Everything that violates the policy is now blocked.
Trusted Types is a second step on the same path. Ship it months later, once CSP is stable.
The traps
Six ways to write a CSP that looks right but is not.
Disables the policy. Any inline script runs. Never put this in script-src.
Same as 'unsafe-inline'. Any origin can serve a script. Common in copy-paste starter policies.
The 94% case. Almost certainly has a bypass. Use the strict pattern instead.
Some rules (frame-ancestors, report-to) are ignored inside a meta tag. Use the HTTP header.
Both close known bypasses and cost nothing. Keep them in the minimum strict pattern.
Skipping Report-Only ships broken features to real users. Stage.
The minimum strict policy:
Content-Security-Policy:
script-src 'nonce-{random}' 'strict-dynamic';
object-src 'none';
base-uri 'none';
frame-ancestors 'none';
report-to csp
Reporting-Endpoints: csp="https://your.endpoint/csp-report"Four rules plus reporting. Ship it in Report-Only, watch what breaks, fix it, enforce. Result: a defence against the attack that has led the OWASP Top 10 for twenty years.
Check whether your CSP stops XSS or just looks like it does: scan your domain. Sudory reads the response header, tests it against known bypass patterns, and gives you the verdict.