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.

Without CSPcompromised
  1. 01attacker submits a comment: <script>steal()</script>
  2. 02server embeds the comment in the HTML
  3. 03browser parses HTML, sees the script tag
  4. 04browser runs the script
With CSPattack blocked
  1. 01attacker submits a comment: <script>steal()</script>
  2. 02server sends the HTML + Content-Security-Policy header
  3. 03browser parses HTML with the policy in effect
  4. 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?

Reading the policy
script-src'self''nonce-r7F2p'
'self'scripts served from the same origin as the page
nonceany script tag carrying nonce="r7F2p"
three scripts on this page
/app.jssame origin, matches 'self'runs
evil.com/x.jsdifferent origin, not on the listblocked
inline <script>alert(1)</script>no nonce attributeblocked

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.

How one allowlist entry leaks
your policy trusts
cdn.jsdelivr.net
but jsDelivr hosts
every version of every npm package ever published
attacker picks
an old angular.js URL
angular.js has
a template syntax that evaluates strings as code
your CSP blocks nothing. the attack runs.

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.

Nonce matching
server
4Rhbyf
fresh per request
header:
Content-Security-Policy: script-src 'nonce-4Rhbyf'
in the HTML:
<script nonce="4Rhbyf">
browser
4Rhbyf4Rhbyf
match, run
attacker's injected script
<script>steal()</script>nonce attribute:missingblocked

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.

Trust cascade with 'strict-dynamic'
you mark this<script nonce="r7F2p">
script injected
by the root
script injected
by the root
script injected
by the root
nested
nested
all trusted
injected in the HTML
<script>steal()</script>
no parent in the trust tree
blocked

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 cooked

No <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:

Beforedom xss
  1. 01el.innerHTML = userInput
  2. 02browser parses it as HTML
  3. 03attacker-supplied code runs
With Trusted Typessafe
  1. 01el.innerHTML = userInput
  2. 02browser throws TypeError
  3. 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.

Staged rollout
  1. Week 0

    Ship in Report-Only

    Deploy the strict-CSP skeleton as Content-Security-Policy-Report-Only. Nothing is blocked, violations are reported.

  2. 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.

  3. Week 2

    Fix the noise

    Add nonces to real inline scripts, remove inline event handlers (onclick=...), rewrite third-party loaders that fight strict-dynamic.

  4. 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.

script-src 'unsafe-inline'

Disables the policy. Any inline script runs. Never put this in script-src.

script-src *

Same as 'unsafe-inline'. Any origin can serve a script. Common in copy-paste starter policies.

allowlist without 'strict-dynamic'

The 94% case. Almost certainly has a bypass. Use the strict pattern instead.

CSP shipped only via <meta> tag

Some rules (frame-ancestors, report-to) are ignored inside a meta tag. Use the HTTP header.

missing object-src 'none' and base-uri 'none'

Both close known bypasses and cost nothing. Keep them in the minimum strict pattern.

enforcing on day one

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.