A “content security policy header not set” finding means your web server returns HTML pages without a Content-Security-Policy header, so the browser places no limits on where scripts, frames and plugins may load from. Fix it by sending a restrictive policy such as default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self', tested first in report-only mode.
What the scanner is actually detecting
All three scanners that commonly raise this finding run a passive check: they request pages and inspect the response. No attack payload is sent.
| Scanner | Finding title | ID | Vendor rating |
|---|---|---|---|
| Tenable Web App Scanning | Missing Content Security Policy | Plugin 112551 | Low |
| ZAP | Content Security Policy (CSP) Header Not Set | Rule 10038 (alert 10038-1) | Medium |
| Acunetix | Content Security Policy (CSP) Not Implemented | See your scan report | Informational |
The details differ slightly. Tenable’s solution text accepts either a Content-Security-Policy header or a <meta http-equiv="Content-Security-Policy"> tag. ZAP also looks for a policy in a meta tag, ignores redirects and non-HTML responses unless the rule runs at the Low alert threshold, and raises separate informational alerts when it finds only a Content-Security-Policy-Report-Only header (10038-3) or the obsolete X-Content-Security-Policy or X-WebKit-CSP headers (10038-2). None of these checks grade the quality of a policy you already have; that is a separate test.
Real-world risk
A missing CSP is not exploitable on its own. Nobody can attack a site simply because the header is absent. The header matters when another flaw exists: if an attacker finds a stored or reflected cross-site scripting (XSS) bug, a policy that blocks inline script and limits script sources can stop many payloads from running. The frame-ancestors directive also blocks clickjacking, object-src 'none' shuts out plugin content, and base-uri stops an injected <base> tag from redirecting relative script URLs.
OWASP describes CSP as a defense-in-depth layer that should not be the only protection against XSS; output encoding and input handling in the application still come first. That is why vendors rate this finding anywhere from informational to medium. Give it more weight on applications with logged-in users, user-generated content or admin panels, and less on static brochure sites.
How to confirm it on the host
Test the exact URLs listed in the finding. This prints any CSP header, including the report-only variant:
curl -s -D - -o /dev/null https://app.example.com/ | grep -i '^content-security-policy'
Check an error page as well, because headers are often only added to successful responses:
curl -s -D - -o /dev/null https://app.example.com/does-not-exist | grep -i '^content-security-policy'
Look for a policy delivered in the page body instead of a header:
curl -s https://app.example.com/ | grep -io '<meta[^>]*content-security-policy[^>]*>'
On Windows, PowerShell gives the same answer for pages that return 200:
$r = Invoke-WebRequest -Uri https://app.example.com/ -UseBasicParsing
$r.Headers['Content-Security-Policy']
$r.Headers['Content-Security-Policy-Report-Only']
No output confirms the finding. A report-only header on its own also confirms it, since nothing is enforced.
How to fix it
Step 1: choose a starting policy
| Directive | What it does |
|---|---|
| default-src ‘self’ | Fallback for all fetch directives (scripts, styles, images, fonts, connections): same origin only |
| object-src ‘none’ | Blocks <object> and <embed> content |
| base-uri ‘self’ | Restricts the URLs allowed in a <base> element |
| frame-ancestors ‘self’ | Only your own origin may frame the page. It does not fall back to default-src and only works as a header, not in a meta tag |
OWASP’s basic policy also adds form-action 'self', which is worth including if no form posts to another site.
Step 2: run it in report-only mode
Content-Security-Policy-Report-Only reports violations without blocking anything. It cannot be set in a meta tag. MDN recommends sending both report-uri and report-to until report-to is broadly supported. In nginx:
add_header Reporting-Endpoints 'csp-endpoint="https://app.example.com/csp-report"' always;
add_header Content-Security-Policy-Report-Only "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; report-uri /csp-report; report-to csp-endpoint" always;
The endpoint must accept POSTed JSON. For a small application, clicking through every workflow with the browser developer console open is often enough to see the violations.
Step 3: fix what the reports show
- Third-party hosts: add them to the specific directive, for example
script-src 'self' https://cdn.example.net, rather than widening default-src. - Inline scripts: move them to external files, or allow them with a nonce or hash. A nonce must be random and unique per response, so the application has to generate it; a static header in nginx or Apache cannot.
- Inline event handlers: attributes such as
onclick="..."are blocked; rewrite them withaddEventListener. - Avoid ‘unsafe-inline’ in script-src: it removes most of the protection against injected script. Browsers ignore it when a nonce or hash is also present.
For a fixed inline script, compute the hash of the exact text between the tags, whitespace included, and add it to script-src as 'sha256-<value>':
printf '%s' 'console.log("ready");' | openssl dgst -sha256 -binary | openssl base64 -A
Step 4: enforce the policy
nginx (server or http block):
add_header Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" always;
Without always (nginx 1.7.5 and later), the header is only added to 200, 201, 204, 206, 301, 302, 303, 304, 307 and 308 responses, so error pages go out bare. add_header directives are inherited only if the current level defines none, so a location block with its own add_header drops the server-level CSP; repeat it there, or on nginx 1.29.3 and later use add_header_inherit merge;. If the upstream application already sends a policy, pick one source: two CSP headers are both enforced, so the effective policy becomes stricter than either one.
sudo nginx -t && sudo systemctl reload nginx
Apache HTTP Server (requires mod_headers; a2enmod headers on Debian and Ubuntu):
Header always set Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'"
If a proxied or FastCGI backend also sends the header, add Header onsuccess unset Content-Security-Policy before that line to avoid duplicates, as the mod_headers documentation describes. Then run apachectl configtest and sudo systemctl reload apache2 (the unit is httpd on RHEL-family systems).
IIS (site web.config):
<configuration>
<system.webServer>
<httpProtocol>
<customHeaders>
<add name="Content-Security-Policy" value="default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" />
</customHeaders>
</httpProtocol>
</system.webServer>
</configuration>
In IIS Manager: select the site, open HTTP Response Headers, click Add. The appcmd /+"customHeaders.[name='...',value='...']" syntax wraps the value in single quotes, which collide with the quotes inside CSP keywords, so web.config or the GUI is simpler here.
Application middleware is the right place once you need nonces. Django 6.0 ships django.middleware.csp.ContentSecurityPolicyMiddleware:
from django.utils.csp import CSP
SECURE_CSP = {
"default-src": [CSP.SELF],
"object-src": [CSP.NONE],
"base-uri": [CSP.SELF],
"frame-ancestors": [CSP.SELF],
"script-src": [CSP.SELF, CSP.NONCE],
}
Use SECURE_CSP_REPORT_ONLY for the test phase and nonce="{{ csp_nonce }}" in templates (with the django.template.context_processors.csp context processor). Spring Security does not add CSP by default; configure it with headers.contentSecurityPolicy(csp -> csp.policyDirectives("...")), adding .reportOnly() while testing.
How to verify the fix and rescan
Rerun the curl commands against the finding’s URLs and an error page. Each HTML response should carry exactly one Content-Security-Policy header (not only the report-only one). In the browser, the Network tab shows the header and the Console lists any “Refused to load” or “Refused to execute” violations; walk through login, forms, uploads and dashboards before closing the change.
Then rescan: rerun the Tenable WAS scan, re-spider the site through ZAP (its passive rules run on every response ZAP sees), or rescan in Acunetix. Header findings are runtime observations that only a dynamic scan produces (see what SAST, DAST and SCA scanners can each detect), so a code review alone will not close them.
What can break and how to roll back
- Inline scripts, handlers and
javascript:links stop working. - eval() and similar calls in older libraries fail unless you add
'unsafe-eval'. - Third-party resources (analytics, tag managers, web fonts, CDN scripts) are blocked until listed.
- data: images need
img-src 'self' data:. - API calls to other hosts need
connect-srcentries. - Legitimate framing by another origin, such as an intranet portal, needs that origin in
frame-ancestors.
Rollback is immediate because only a header changes. Rename the header back to Content-Security-Policy-Report-Only to keep visibility without blocking, or remove the line, then run the config test and reload. In Django, move the dictionary from SECURE_CSP to SECURE_CSP_REPORT_ONLY.
Common false positive reasons
- Policy in a meta tag: Tenable and ZAP accept it, but other checks may only read headers. A meta policy cannot carry frame-ancestors or reporting, so moving it to a header is better anyway.
- Non-HTML responses: JSON, images and downloads flagged by ZAP at the Low threshold do not need a policy; document them as accepted.
- Edge devices: a CDN, WAF or load balancer that strips or replaces headers means the scanner sees a different response from the origin.
- Different virtual host: scanning by IP address often reaches an unconfigured default site.
- Stale results: the scan ran before the reload, or a cache served old headers.
Report-only headers, obsolete X-Content-Security-Policy headers and headers missing only on error pages are not false positives.
FAQ
Is a missing CSP header a vulnerability on its own?
No. It is a missing mitigation. Without it, XSS and clickjacking flaws that do exist are easier to exploit, which is why Tenable and Acunetix rate it low or informational.
Does Content-Security-Policy-Report-Only fix the finding?
No. It enforces nothing, and ZAP reports it as a separate informational alert. Use it for testing, then switch to the enforcing header.
Can I just add ‘unsafe-inline’ to make everything work?
For script-src, that gives up most of the XSS protection. Use nonces or hashes instead. Teams sometimes accept it in style-src as an interim step, but keep it out of script-src.
Does CSP replace X-Frame-Options?
Browsers that support CSP use frame-ancestors and ignore X-Frame-Options, but many scanners still check for X-Frame-Options, so send both.
Tracking this finding across many hosts
Header findings usually appear on many virtual hosts at once. SITEY, a self-hosted vulnerability management platform, imports findings from 16 scanners and merges duplicates within each scanner but not across them, so the same missing header reported by two scanners stays as two records. For Acunetix, Nessus and Burp findings it can run a per-finding retest to confirm the header is sent before an item is closed.