0
Defense INTERMEDIATE 15 min read Updated

Content Security Policy: Stopping XSS with Headers

Content Security Policy is the browser control that limits what a page can load and run. Learn the directives, nonces, strict-dynamic, report-only rollout, and a hardened policy you can ship.

founder & security researcher · 39 writeups
Content Security Policy: Stopping XSS with Headers

Content Security Policy is the response header that tells a browser which scripts, styles, images, and connections a page is allowed to load and run, and refuses everything else. When an attacker manages to inject a payload into your page, the browser has no idea that the extra script is unwelcome. A good policy gives it the rule it was missing, so the injected code never executes even though the bug that let it in is still there. That gap between a bug existing and a bug paying off is exactly where this header earns its place.

This guide walks through what the policy does, every directive worth setting, the two keywords that quietly cancel its protection, and how nonces, hashes, and strict-dynamic let you keep the inline code you actually need. It ends on rollout and detection, because a policy you cannot deploy without breaking the site is a policy nobody keeps.

Scope

Examples use the project lab host lab.0xpriv.dev. Set and test policy changes on an app you own or are authorized to configure, ideally in staging first. Report-only mode exists so you can do this without breaking production for real users.

01What Content Security Policy is and where it fits

Content Security Policy, almost always shortened to CSP, is an allowlist delivered as an HTTP response header that constrains the sources a page may pull content from and the kinds of code it may execute. The browser reads the header once per response and enforces it for the lifetime of that document. Nothing on the page can turn it off, which is what makes it useful against injected markup.

It is a second line of defense, not the first. The first line is output encoding: escaping untrusted data for the exact context it lands in, so a value meant as text can never be read as HTML or script. Get that right everywhere and you have no cross-site scripting bug to begin with. The trouble is that real applications are large, contexts are easy to miss, and one unescaped sink is all an attacker needs. CSP sits behind the encoding and catches what slips past, limiting the damage of a bug you have not found yet.

where the policy sits in a request
Serveradds CSP header response Browserenforces policy allow trusted script runs block injected script dies

02How the header works and the main directives

The header itself is a list of directives separated by semicolons, and each directive names a resource type followed by the sources the browser should trust for it. A source can be a keyword such as 'self', a scheme such as https:, a host such as cdn.0xpriv.dev, or a nonce or hash. If a resource does not match any source in its directive, the browser refuses to load or run it and logs a violation.

One directive underpins the rest. default-src is the fallback for most fetch directives, so any resource type you do not name explicitly inherits whatever you set there. A few directives do not fall back to it, which is a common source of mistakes: frame-ancestors, base-uri, form-action, and the reporting directives all stand alone and must be set directly. The table below is the working set you will reach for on almost every policy.

DirectiveControls
default-srcFallback source list for most fetch directives you do not set
script-srcWhere scripts may come from, and whether inline or eval is allowed
style-srcWhere stylesheets and inline styles may come from
img-srcWhere images may load from, including data: URIs
connect-srcTargets for fetch, XHR, WebSocket, EventSource, and sendBeacon
frame-ancestorsWho may embed this page in a frame (anti-clickjacking)
base-uriValues allowed in a <base> tag, which blocks base-tag hijacking
object-srcSources for plugins such as <object> and <embed>
form-actionWhere forms on the page may submit
reading the policy a server actually sends
$ curl -sI https://lab.0xpriv.dev/ | grep -i content-security-policy
content-security-policy: default-src 'none'; script-src 'nonce-2726c7f26c8a'
 'strict-dynamic'; style-src 'self'; img-src 'self' data:; connect-src 'self';
 frame-ancestors 'none'; base-uri 'none'; object-src 'none'; form-action 'self'

Two of these matter more than their size suggests. object-src 'none' kills legacy plugin content outright, which removes an old script-execution path for almost no cost. base-uri 'none' stops an attacker who can inject a <base> tag from rewriting every relative URL on the page toward a host they control. Both belong in a strict policy even though neither relates to loading a normal script.

03Why unsafe-inline and unsafe-eval undermine the policy

The keywords 'unsafe-inline' and 'unsafe-eval' are the two settings that hand most of CSP's protection back to the attacker, and they are named honestly. 'unsafe-inline' allows inline scripts, inline event handlers such as onclick, and javascript: URIs. That is precisely the shape an injected cross-site scripting payload takes, so a script-src that includes 'unsafe-inline' blocks essentially nothing. 'unsafe-eval' permits eval(), the Function constructor, and string arguments to setTimeout, which reopens a different class of injection.

The painful part is why they get added. Teams turn on CSP, the site breaks because it has inline scripts and handlers everywhere, and 'unsafe-inline' makes the breakage stop. The policy now passes a scanner and provides close to zero defense. The fix is not to accept that trade. It is to stop relying on inline code you cannot vouch for, and to allow the specific inline code you do need by identity, through a nonce or a hash.

The quiet failure

A policy with script-src 'self' 'unsafe-inline' looks locked down and reports as present, but an injected <script>alert(1)</script> still runs. Presence of a header is not protection. Check the actual sources.

04Allowing inline script safely with nonces and hashes

A nonce or a hash is how you keep a known inline script while the browser still refuses every injected one. A nonce is a random value you generate fresh on every response, put in the header, and repeat as an attribute on each script tag you trust. The browser runs only the inline scripts whose nonce attribute matches the header value. An attacker injecting markup cannot guess the value, so their script has no valid nonce and is rejected.

html
<!-- the server generates a new random nonce for every response -->
<!-- header: script-src 'nonce-2726c7f26c8a' -->
<script nonce="2726c7f26c8a">
  initDashboard();
</script>

A hash is the stateless alternative for inline code that never changes. You take the SHA-256 (or 384 or 512) digest of the exact bytes between the script tags, base64 encode it, and list it as 'sha256-...' in the directive. No server-side randomness is needed, which suits static pages, but the hash breaks the moment the script content changes by a single byte. The browser is helpful here: when it blocks an inline script it prints the exact hash it expected in the console, so you copy that value straight into the directive.

nginx
# a nonce for dynamic pages, a hash for a fixed inline snippet
script-src 'nonce-2726c7f26c8a';
script-src 'sha256-B2yPHKaXnvFWtRChIbabYmUBFZdVfKKXHbWtWidDVF8=';
Nonces must be random and per-response

Reuse the same nonce across responses and an attacker can read it from one page and reuse it in an injection. Generate at least 128 bits from a cryptographic source each time, and never hardcode it.

05strict-dynamic and a modern strict policy

The 'strict-dynamic' keyword is what turns a nonce-based policy into one that scales to real applications with many scripts. Nonces work until a trusted script loads another script dynamically, which has no nonce and gets blocked. 'strict-dynamic' says that any script the browser already trusts, because it had a valid nonce or hash, may load further scripts, while host-based allowlisting and 'unsafe-inline' in that directive are ignored. You stop maintaining a list of CDN hosts and trust propagates from the nonce outward.

That gives the policy recommended by current guidance: no host allowlist for scripts at all, just a nonce and strict-dynamic, with a few keywords left in only so older browsers degrade sanely. A full header looks like this.

nginx
# 0xpriv.dev/defense  -- a strict, nonce-based policy
Content-Security-Policy:
  default-src 'none';
  script-src 'nonce-2726c7f26c8a' 'strict-dynamic' 'unsafe-inline' https:;
  style-src 'self';
  img-src 'self' data:;
  connect-src 'self';
  frame-ancestors 'none';
  base-uri 'none';
  object-src 'none';
  form-action 'self';
  report-uri https://lab.0xpriv.dev/csp-report

Two parts of that look wrong until you know the rule. The 'unsafe-inline' and https: inside script-src are there only for browsers too old to understand nonces and strict-dynamic. A modern browser that sees a nonce ignores both, so you get strict enforcement where it is supported and a looser but still present policy where it is not. default-src 'none' means every resource type you did not list is denied by default, so you add directives only for what the page genuinely uses.

how strict-dynamic propagates trust
Script with noncetrusted loads Child script, no nonceallowed by strict-dynamic injected script: blocked page runs

06Report-only mode and rollout with reporting

Report-only mode is the setting that lets you roll a policy out without breaking anything, and skipping it is the usual reason CSP projects stall. Send the policy in a Content-Security-Policy-Report-Only header instead of the enforcing one and the browser evaluates every rule, blocks nothing, and sends a JSON report for each violation it would have caused. You watch those reports in production for a week or two, fix what the site actually trips on, and only then switch to the enforcing header.

nginx
# evaluate the policy, block nothing, report every violation
Content-Security-Policy-Report-Only:
  default-src 'none'; script-src 'nonce-2726c7f26c8a' 'strict-dynamic';
  report-uri https://lab.0xpriv.dev/csp-report

Reporting comes in two forms. report-uri is the older directive and posts a small JSON body to the URL you give it; it is deprecated but still the most widely supported. report-to is the replacement and points at a named endpoint group that you declare separately in a Reporting-Endpoints header. Because browser support is still mixed, the common pattern is to send both and let each browser use the one it understands.

nginx
# the modern reporting pair: name a group, then point report-to at it
Reporting-Endpoints: csp-endpoint="https://lab.0xpriv.dev/csp-report"
Content-Security-Policy: default-src 'none'; report-to csp-endpoint; report-uri https://lab.0xpriv.dev/csp-report
Report-only and enforce can run together

You can send both headers at once: an enforcing policy you trust, plus a stricter report-only policy you are testing. The site stays up on the first while you collect data toward the second.

07Testing a policy and the breakage you will hit

Testing a policy means loading the site with the header on and reading what the browser tells you, and the fastest place to do that is browser devtools. Every blocked resource prints a violation in the console that names the directive it broke and the resource it refused, which is usually enough to see the fix. Work through the pages that matter, click the features, and clear the console as you go.

a CSP violation in the devtools console
> Refused to execute inline script because it violates the following
Content Security Policy directive: "script-src 'nonce-...' 'strict-dynamic'".
Either the 'unsafe-inline' keyword, a hash, or a nonce is required to enable
inline execution.

The breakage is predictable. Inline event handlers like onclick in the HTML stop firing, because a nonce covers <script> blocks but not inline handler attributes. Inline styles may be refused if style-src is tight. Third-party widgets that inject their own scripts fail unless strict-dynamic carries the trust for them. The repair pattern is the same each time: move inline handlers into a nonce-covered script that calls addEventListener, give legitimate inline code a nonce or hash, and never reach for 'unsafe-inline' to make the console quiet.

Inline handlers are not covered by a nonce

A nonce applies to script elements, not to onclick or onload attributes. Those need 'unsafe-hashes' with a matching hash, or better, moving the handler into real JavaScript. Treat a surviving inline handler as work to do, not a reason to loosen the policy.

08Hardening a real policy step by step

Hardening a real policy is a process of starting from whatever the site runs on today and tightening one directive at a time, verifying nothing breaks after each change. Teams that try to jump straight to the strict policy usually break the site and revert the whole thing. The steps below take a loose starting point to a strict one without a flag day.

nginx
# the loose starting point most sites actually run
Content-Security-Policy: default-src *; script-src 'self' 'unsafe-inline' 'unsafe-eval' https:; style-src 'self' 'unsafe-inline'

Pin the easy directives

Add object-src 'none', base-uri 'none', and frame-ancestors 'none'. These rarely break anything and close real attack paths immediately.

Turn on report-only for scripts

Ship a second Content-Security-Policy-Report-Only header with a nonce-based script-src and collect violations from real traffic for a week.

Remove unsafe-eval, then unsafe-inline

Fix the eval and inline code the reports flagged, add nonces to the scripts you keep, and drop both keywords from the enforcing policy.

Adopt strict-dynamic and drop host lists

Switch script-src to 'nonce-...' 'strict-dynamic' so trust flows from the nonce and you stop maintaining CDN allowlists.

Tighten the rest to 'self' or 'none'

Replace default-src * with default-src 'none' and add back only the img-src, style-src, and connect-src sources the site truly uses.

End that process and you are running the strict header shown earlier, reached in increments you could verify and roll back one at a time rather than in a single risky change.

09The limits of CSP

CSP has clear limits, and the one to internalize is that it mitigates cross-site scripting, it does not fix the bug. The injection is still in your code. CSP only ensures that the injected payload has nowhere to run, which depends entirely on the policy being strict. A policy with 'unsafe-inline' provides almost nothing, and a single overly broad host in script-src can give an attacker a trusted origin to host their payload on.

There are whole categories it does not touch. It does nothing for data exfiltration through a domain you already allowed, nothing for logic flaws, and little for DOM-based sinks where attacker-controlled data reaches innerHTML and the executing script came from your own trusted origin. Treat the header as damage control layered on top of correct output encoding and framework autoescaping, never as a replacement for fixing the injection. If you remove the encoding because you added a policy, you have gone backward.

Not a substitute for the fix

Shipping CSP and leaving a known XSS sink unescaped is a bet that your policy is flawless. Policies have gaps, bypasses exist for weak ones, and the bug is still exploitable the day someone loosens a directive. Fix the sink too.

10Detecting attacks from CSP reports

Detecting attacks with CSP leans on the same reporting you set up for rollout, because a violation report is a signal that something tried to load or run content your policy forbids. Once a policy is stable and your own pages stop generating noise, a sudden cluster of script-src violations referencing an inline script or an unfamiliar external host is a strong indicator that someone found an injection point and the policy caught the payload.

Feed the reports to your logging pipeline and alert on the shape rather than any single report. A spike in script-src or connect-src violations tied to one URL parameter or one page, especially ones pointing at a host you never allowed, is the fingerprint of an XSS attempt being blocked in production. That is a bug to go fix at the source, with the reassurance that the header bought you time.

11Shutting it down

Pulling the pieces together, a policy that actually stops cross-site scripting comes down to a short list of decisions applied consistently.

How to shut it down
Encode output first. CSP is the second line. Context-aware escaping and framework autoescaping remove the bug; the header only limits what a missed bug can do.
Never ship 'unsafe-inline' in script-src. It cancels the protection. Use a per-response nonce or a hash for the inline code you genuinely need.
Prefer nonces plus strict-dynamic. Trust flows from the nonce, so you drop fragile host allowlists and still run your own and third-party scripts.
Set default-src 'none' and add back only what you use. Deny by default, then allow img-src, style-src, and connect-src explicitly.
Lock object-src, base-uri, and frame-ancestors to 'none'. Cheap directives that close plugin execution, base-tag hijacking, and clickjacking.
Roll out with report-only, then watch the reports. Deploy without breaking users, fix what the reports flag, enforce, and keep the reports as an XSS detector.

12FAQ

Does Content Security Policy replace escaping and output encoding?FAQ

No. Output encoding is the primary fix that removes the cross-site scripting bug, and CSP is a second line that limits the impact of a bug you missed. Keep both. A policy with any weak directive provides little on its own, and removing your encoding because you added a header makes the application less safe, not more.

Is 'unsafe-inline' ever acceptable in script-src?FAQ

Practically never for scripts, because it allows exactly the inline payloads an attacker injects, so it cancels the protection against XSS. Allow specific inline scripts with a nonce or a hash instead. It is slightly more defensible in style-src, where the risk is lower, but a nonce or hash there is still better.

What is the difference between report-uri and report-to?FAQ

report-uri is the older directive that posts a JSON violation report to a URL and is still the most widely supported. report-to is its replacement, pointing at a named endpoint group declared in a Reporting-Endpoints header. Because support varies, many sites send both so every browser can use the one it understands.

Why does my inline onclick still break with a nonce set?FAQ

A nonce applies to <script> elements, not to inline event handler attributes like onclick or onload. Those require 'unsafe-hashes' with a matching hash, or, better, moving the handler into a nonce-covered script that calls addEventListener. Keeping inline handlers out of the HTML is the cleaner long-term answer.

Does CSP stop DOM-based XSS?FAQ

Only partly. If attacker-controlled data reaches a sink like innerHTML and the script doing it came from your own trusted origin, the policy does not block that script. CSP helps most against injected external and inline scripts. For DOM sinks, combine a strict policy with Trusted Types, which forces dangerous sinks to accept only vetted values.

references

Was this writeup useful?
Alex Rowan Founder & Security Researcher at 0xpriv

Founder of 0xpriv, covering offensive security, web application vulnerabilities, and practical security research through PHP, Python, and hands-on technical analysis.

related writeups

more in Defense →

discussion

0 comments
`code` supported · comments are reviewed before they appear