Defaults.Exposed

Most Content-Security-Policy headers are not content security policies

Your Content-Security-Policy header is going out, the audit ticket closed on it, and whether it protects anything comes down to one question: does the policy contain a default-src or a script-src directive? If it doesn’t, no script on your pages is constrained by it. The browser reads the header, finds nothing that limits where code may load from, and runs whatever the page asks it to run, the same as it would with no header at all.

A header being present and a policy being enforced are two readings of the same line, and they come apart more often than you’d expect. Most of the CSP headers the September 2026 census saw pass the first reading and fail the second, and one command tells you which side yours is on.

The one-line test

Pull the header from your own site and read what comes back:

curl -sI https://<your-domain> | grep -i content-security-policy

Look for two words in the value. If default-src or script-src appears, the policy constrains script and you’re past the first hurdle. If neither appears, the policy sets no boundary on where code may come from, whatever else it contains.

Three values you’ll commonly meet, and how each scores on that test:

Content-Security-Policy: upgrade-insecure-requests

This rewrites http subresource URLs to https and constrains no script.

Content-Security-Policy: frame-ancestors 'self'

This stops other sites framing yours and constrains no script.

Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'

This limits script to your own origin and passes.

The scan on this page reads the same header line the census read, and its row tells you what the policy does instead of whether it exists.

What upgrade-insecure-requests does, and what it leaves alone

A policy that reads upgrade-insecure-requests and nothing else is common enough that the census counts it as its own category. When a page served over https references an image, script or stylesheet by an http:// URL, the browser upgrades that fetch to https:// before sending it. The mixed-content warning disappears and the resource loads over TLS. The same upgrade applies to form submissions and to links within your own site. If you moved a site to https and had a long tail of hard-coded http links in old templates, this directive is what brought the padlock back.

Changing the scheme of a URL is all the directive does. It has no opinion on which hosts may serve scripts, whether inline <script> blocks run, whether eval is permitted, or whether a third party can inject a tag through a compromised dependency. A page with upgrade-insecure-requests alone will load and execute a script from any origin an attacker manages to reference, over https, with the browser’s full cooperation.

It doesn’t reach links that take the user to another site either, so a link out to an http:// destination stays http. And it isn’t a substitute for HSTS: the CSP directive only exists once the page has been fetched, whereas HSTS tells the browser to use https before the first request leaves. The two solve neighbouring problems, and neither one restricts content.

What the September 2026 census counted

The census records the response headers of each domain that answers on the web and keeps the contents of the Content-Security-Policy value, so the census can count the gap between declared and enforced instead of estimating it. Three lines from the September 2026 edition show the picture, and each line below names the denominator the census uses for it.

Of the 214,006,060 domains that sent a Server, Content-Security-Policy or X-Powered-By header, 21,421,283 (10.01% of http_headers_present) sent any Content-Security-Policy header at all. Publishing a CSP of any kind remains a minority choice.

Of those 21,421,283 domains, 6,687,908 (31.22% of csp_present) sent a policy consisting of upgrade-insecure-requests and nothing else, the value that fails the one-line test above.

And 5,324,566 domains (24.86% of csp_present, 2.49% of http_headers_present) had a default-src or a script-src. That is the share of the web’s CSP headers that constrain script at all. It doesn’t say how well they do it. A script-src that allows 'unsafe-inline' and a wildcard host has a directive in the right place and very little behind it, so 24.86% is the generous reading, and the other CSP headers don’t get even that far.

Keep the two denominators apart when you repeat these numbers. The 10.01% is a share of domains that sent headers; the 31.22% and the 24.86% are shares of the smaller group that sent a CSP, and a figure built by quoting one against the other’s denominator is one the census never counted.

Why the ticket closed anyway

A checklist that tests for presence has one row for this header. It reads “Content-Security-Policy: present” or “missing”, and it turns green when any value goes out. The ticket raised on the red row closes when the row changes colour, and nothing in that loop parses directives. A header of upgrade-insecure-requests satisfies it as fully as a policy that took three weeks to tune.

The other reason is that writing a CSP that constrains script is work. You have to inventory each host your pages load code from, then deal with the inline event handlers and analytics snippets the policy will block. Publishing upgrade-insecure-requests is one line that breaks nothing, and the row turns green. Given a checklist that can’t tell the difference, the one-liner wins, and the census shows how often it does.

The headers pillar covers what a scanner can and can’t infer from a response. The failure mode described here survives any tool that stops at the header name, and the checklist format encourages stopping there.

Reading a policy for what it enforces

Once you’ve got past the presence test, read the policy the way the browser does. Fetch directives (script-src, style-src, img-src, connect-src, frame-src and the rest) each govern one kind of load, and default-src is the fallback for any fetch directive you leave unset. So default-src 'self' on its own already limits scripts, styles, images, fonts, frames and connections to your origin, and each specific directive you add widens or narrows that for one category.

Then look at what the script directive allows. 'unsafe-inline' permits inline scripts and inline event handlers, which is where most injected code arrives, so its presence in script-src largely cancels the constraint. 'unsafe-eval' allows string-to-code functions. A bare scheme such as https: or a wildcard host allows any script from any https origin. Through any of these a policy can have script-src and still allow nearly everything. The census line only counts whether the directive is there, so you’ll need to read what it allows yourself.

Two directives that aren’t fetch directives still matter. frame-ancestors controls who may embed your pages, and is the CSP replacement for the older X-Frame-Options header. base-uri stops an injected <base> tag redirecting your relative script URLs to a host of the attacker’s choosing. Neither constrains script, and a policy made only of them would fail the test in the first section, but both belong in the finished header.

What to publish instead

Start from default-src 'self', then set script-src explicitly with the origins that serve your JavaScript. Add object-src 'none' to shut off plugin content and base-uri 'self' to close the base-tag bypass. If you have inline scripts you can’t remove yet, give them a per-response nonce. 'unsafe-inline' would switch the constraint back off. Keep frame-ancestors for clickjacking, and keep upgrade-insecure-requests for mixed content, since it was only ever a problem on its own.

A policy that constrains script and reports on itself looks like this:

Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-<per-response-value>'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; upgrade-insecure-requests; report-to csp-endpoint
Reporting-Endpoints: csp-endpoint="https://<your-domain>/csp-reports"

The report-to directive needs the matching Reporting-Endpoints header to know where to send violations. Older browsers only understand report-uri, so during the transition it’s reasonable to send both, pointing at the same endpoint.

A CSP also depends on the page reaching the browser over TLS intact. A downgraded or intercepted response arrives with whatever header the attacker chose to put on it, so sort the transport out before you spend time tuning the policy.

Report-only first, then enforce

Don’t ship the enforcing header cold on a site with real traffic. Publish the same value under Content-Security-Policy-Report-Only, point report-to at an endpoint you can read, and let it run for a couple of weeks. Report-only policies block nothing. They send a violation report for each fetch the enforcing version would have refused, which is how you find the analytics tag loading from a host you’d forgotten and the inline handler in the footer template. Fix or allow each one, and once the reports go quiet, rename the header.

Reporting earns its keep after the switch as well. A CSP with no report-to or report-uri blocks silently, so a deployment that adds a new script host will break a feature and nobody will hear about it until a user complains. Reporting is the part it’s tempting to skip, and it’s the part that makes the rest safe to change. Keep the endpoint live and keep reading it once the header is enforcing.

Figures as of 5 September 2026, from the September 2026 edition of the defaults.exposed census. Census numbers move every month; the current values are on the census data page.