Your Content-Security-Policy is not actually blocking anything
why does my content-security-policy header not block XSS Your CSP is likely doing nothing because it ships as Content-Security-Policy-Report-Only with no report endpoint, or the enforcing policy allows 'unsafe-inline' for script-src/style-src or a wildcard img-src. Move to nonce or hash based script-src, drop unsafe-inline, and verify by checking the header name and inline scripts in the rendered HTML.
Seen in 24 of 450 scanned projects (5%). based on Deep Scan header and HTML inspection across 450 audited production sites
How to tell you have it
- Response header is Content-Security-Policy-Report-Only, never Content-Security-Policy
- script-src or style-src in the policy includes 'unsafe-inline'
- img-src or connect-src is set to * or https: with no host restriction
- report-uri or report-to points at a domain that no longer exists or 404s
- Browser devtools console shows zero CSP violations even on pages with obvious inline scripts
Why it matters
A Content-Security-Policy that only ships as Report-Only never stops a single request. The browser evaluates the policy, logs what would have been blocked to a report endpoint, and then lets the script, style, or image load anyway. Teams often add this header once during a security review, see no crashes, and never flip it to enforcing mode. Months later the header is still there, giving a false sense of protection while an injected script tag would execute without resistance.
'unsafe-inline' on script-src or style-src defeats the entire point of CSP against stored and reflected XSS. If an attacker can get any string into the DOM as an inline <script> or an onclick handler, the browser runs it exactly as if there were no policy at all. Frameworks that inject inline styles for critical CSS or third party widgets that write inline handlers are the usual reason this got added, and it rarely gets removed once the app ships.
A wildcard img-src or connect-src of * or https: quietly enables data exfiltration. Even with script-src locked down, an attacker who finds any injection point (a CSS injection, an open redirect, a misconfigured oembed) can still beacon stolen tokens or page content out to an attacker-controlled host, because the policy never restricts where the browser is allowed to send requests or load images.
An allowlist pointing at the wrong backend host is a quieter failure mode. After a domain migration or a move to a new CDN or API gateway, the policy still lists the old hostname. Legitimate requests to the new host get silently blocked in enforcing mode (breaking functionality), or, worse, the old abandoned hostname gets re-registered by someone else and becomes a trusted origin in your users' browsers.
How Heygents detects it
Deep Scan runs curl -sI against the live URL and inspects whether the response uses Content-Security-Policy or Content-Security-Policy-Report-Only, then parses the directive values for 'unsafe-inline', wildcard sources, and stale hostnames. It also fetches the rendered HTML with curl -s to count inline <script> and <style> tags and inline event handler attributes that would be affected by the policy.
How to fix it
- Confirm the header is enforcing, not just reporting Check the live response header name directly. Report-Only never blocks anything, it only logs. If you see Report-Only in production with no matching enforcing header, treat this as unprotected.
- Remove unsafe-inline and switch to nonces Generate a random nonce per request on the server, add it to every inline <script> tag and to script-src in the header. Do the same for style-src if you have inline styles you cannot move to a stylesheet.
- Prefer hashes for a small fixed set of inline scripts If you have only a handful of static inline scripts (not per-request generated content), hash them instead of using nonces. This avoids needing a nonce plumbed through every render.
- Scope img-src and connect-src to real hosts Replace wildcard sources with the actual list of hosts your app legitimately loads from or sends requests to. Update this list whenever you change CDN, API gateway, or analytics provider.
- Run report-then-enforce for at least one release cycle Ship the tightened policy as Report-Only alongside the current enforcing policy, watch the report endpoint for real violations from legitimate app traffic, fix any false positives, then flip Report-Only to enforcing.
- Verify enforcement after the flip Confirm the header name is enforcing and that a deliberately injected inline script is actually blocked in a real browser, with a console error, not just logged.
Confirm the header is enforcing, not just reporting
curl -sI https://example.com/ | grep -i 'content-security-policy'
Remove unsafe-inline and switch to nonces
// server middleware, per request
const crypto = require('crypto');
const nonce = crypto.randomBytes(16).toString('base64');
res.locals.cspNonce = nonce;
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; " +
`script-src 'self' 'nonce-${nonce}'; ` +
`style-src 'self' 'nonce-${nonce}'; ` +
"img-src 'self' data: example.com; " +
"connect-src 'self' api.example.com; " +
"report-uri /csp-report"
);
// in the template
// <script nonce="<%= cspNonce %>">...</script>
Prefer hashes for a small fixed set of inline scripts
# compute the sha256 hash of an inline script's exact contents
printf '%s' "console.log('boot');" | openssl dgst -sha256 -binary | openssl base64
# add to header: script-src 'self' 'sha256-<result>'
Scope img-src and connect-src to real hosts
Content-Security-Policy: default-src 'self'; img-src 'self' data: cdn.example.com; connect-src 'self' api.example.com; script-src 'self' 'nonce-<value>'; style-src 'self' 'nonce-<value>'; report-uri /csp-report
Verify enforcement after the flip
curl -sI https://example.com/ | grep -i '^content-security-policy:'
# then in browser devtools console on the live page:
# document.body.insertAdjacentHTML('beforeend', '<img src=x onerror="console.log(1)">')
# expect a CSP violation error, not the console.log firing
Find this in your own projects, automatically
Heygents runs a read-only Deep Scan across every project you own, finds issues like this one, and hands you a ready-to-run fix an AI agent can execute and verify. A solo developer gets the audit, the backlog and the fix loop in one place.
Open Heygents →Frequently asked questions
Is Content-Security-Policy-Report-Only useless then?
No, it is the correct first step of a safe rollout. It tells you what the policy would break before you turn on enforcement. The problem is only when it stays Report-Only forever with no plan to switch, or when there is no report endpoint configured so nobody ever looks at the output it produces.
Can I just remove unsafe-inline without nonces or hashes?
Only if you have zero inline scripts and styles left in your rendered HTML. Most apps built with a framework or a third party widget will break immediately, since the browser silently drops any inline script that is not covered by a nonce, a hash, or unsafe-inline once you remove the last one.
Do I need both report-uri and report-to?
report-uri is the legacy directive, report-to is the newer one that some browsers now prefer, and support is inconsistent across browsers. Setting both, pointing at a working endpoint you actually monitor, gives the widest coverage without depending on one browser's implementation choices.
Why does my policy break after moving to a new CDN or API host?
Your img-src, connect-src, or script-src still list the old hostname from before the migration. In enforcing mode the browser blocks any request to a host not on the list, so legitimate assets or API calls fail silently. Update the allowlist as part of every infrastructure or vendor change, not as an afterthought.