Defaults.Exposed

unsafe-inline, and frame-ancestors versus X-Frame-Options: what the web sends

“Refused to execute inline script because it violates the following Content-Security-Policy directive” is the browser telling you it found a <script> block, an onclick= attribute or a javascript: URL written straight into the page’s HTML, checked it against the script-src list in your Content-Security-Policy header, found no entry that permits inline code, and dropped it. The page kept loading without that script, and the console filled up. Then somebody added 'unsafe-inline' to the header and the console went quiet.

The violations stopped because the policy stopped objecting, and the thing it was objecting to is the thing a CSP exists to catch. Read the header left to right and you can see what each part was doing and which part got switched off.

The header line, whole

Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; frame-ancestors 'self'; upgrade-insecure-requests

It’s a policy of the ordinary kind: four directives separated by semicolons, sources separated by spaces, keywords in single quotes. Each of the four does a different job, and only one of them has anything to do with the error you searched for.

The line, one directive at a time

default-src ‘self’

default-src is the fallback. Any fetch directive you don’t name (img-src, style-src, font-src, connect-src and the rest) inherits this list. ‘self’ means the page’s own origin: same scheme, same host, same port. With this line in place, an image from a CDN on another hostname is blocked unless you add an img-src that names it.

default-src covers the fetch directives, the ones that govern what the page loads, and nothing else. It doesn’t reach frame-ancestors, which governs who loads the page, and it doesn’t reach form-action, base-uri or sandbox either. A policy that reads only default-src 'self' leaves the page frameable by anyone.

script-src ‘self’ ‘unsafe-inline’

This is the directive the error named. script-src overrides default-src for script, so the fallback no longer applies to JavaScript and this list is the whole answer to “may this script run?”.

‘self’ allows <script src="/app.js"> and anything else served from the page’s own origin. On its own, ‘self’ also refuses inline script, because an inline script has no origin to match: it’s text inside the document, and a list of origins can’t say anything about text. If an attacker gets text into your page, through a reflected URL parameter or a stored comment, what they inject is inline by definition, and that same refusal drops it with exactly the error you saw.

‘unsafe-inline’ cancels the refusal. With it present, inline <script> blocks, inline event handlers such as onclick=, and javascript: URLs all run. The keyword makes no distinction between the inline script your template engine wrote and the inline script an attacker wrote, because from where the browser stands there isn’t one.

frame-ancestors ‘self’

frame-ancestors answers a different question from the two directives before it. They ask what your page may load. This one asks who may load your page inside an <iframe>, <frame>, <object> or <embed>. ‘self’ means only pages on your own origin may frame you; anyone else gets an empty frame and a console line of their own.

It’s the directive that stops clickjacking, where a hostile page frames yours invisibly and lines its own buttons up over yours. It has nothing to say about script, inline or otherwise. You could delete script-src from this policy and frame-ancestors would carry on working, and you could set script-src as strictly as you like and framing would remain wide open.

upgrade-insecure-requests

The last directive has no source list. It tells the browser to rewrite any http:// URL on the page to https:// before fetching it, so a hard-coded insecure image or stylesheet doesn’t produce a mixed-content warning. It’s a convenience for sites that moved to TLS and still carry old links, the page-level cousin of HSTS, and it constrains nothing. A policy that contains only this directive is doing a find-and-replace on URLs and nothing more. It also assumes the https:// version of each resource answers, which is the TLS check’s business rather than this header’s.

That’s the whole line. Now look at your own header (the wider set it sits in is on the security headers page) and find script-src. If it isn’t there, find default-src. If neither is there, your policy has never had a say over script. The free scan reads the same header the census counted, so you can settle it for your own domain here.

What adding unsafe-inline did

Read the line again with the keyword in it. script-src is still there, overriding default-src with a list that includes ‘self’. The one thing script-src was there for, refusing script that arrived as text rather than as a file from a trusted origin, is switched off. The console cleared for the same reason an injected script would now run unnoticed.

There’s a second effect to know about before the fix. Browsers that support nonces and hashes ignore ‘unsafe-inline’ the moment a nonce or a hash appears in the same directive. A policy carrying both is the strict one in current browsers and the open one in old browsers. That’s by design, and it’s the way out.

What the counted web sends

The September 2026 census counted the Content-Security-Policy header on the domains that sent one, and counted what was inside it, so the line above isn’t a hypothetical and neither is how common each of its parts is.

Take the domains that sent any Content-Security-Policy header as the base. 10,681,691 of them carry frame-ancestors, which is 49.86% of that base. 5,324,566 carry a default-src or a script-src, 24.86% of the same base, and that’s the count of policies that constrain script at all. 4,420,797 carry unsafe-inline, 20.64% of the base.

Put those side by side. Twice as many policies exist to stop the page being framed as exist to constrain script in any way. That’s the header as the counted web sends it: more often a framing control than a script control, arriving under a script-security name.

The same numbers tell you where unsafe-inline means anything. It only has an effect inside the minority of policies that reach script in the first place. A policy with no script-src and no default-src was never constraining inline script, so adding the keyword to it changes nothing, and removing it from one changes nothing either. If your header is one of those, the console error you came in with came from somewhere else: a <meta http-equiv> tag in the HTML, or a second header a proxy is adding on the way out.

frame-ancestors versus X-Frame-Options

Two headers do the framing job, and the census counted one of them, so this section carries no figure for X-Frame-Options and won’t pretend to.

X-Frame-Options is the older header. It carries one of three values: DENY, which forbids framing from anywhere; SAMEORIGIN, which allows framing from the same origin; and a legacy ALLOW-FROM with a single URL, which current browsers ignore. There’s no way to name two allowed origins, and no way to say “my partner’s site and mine”.

frame-ancestors supersedes it. It takes a source list like the other directives, so frame-ancestors 'self' https://partner.example is a valid policy and frame-ancestors 'none' is the equivalent of DENY. When a browser receives both headers on the same response, it uses frame-ancestors and disregards X-Frame-Options. That’s specified behaviour, and it means the CSP directive is the one you’re relying on wherever both are present.

Keeping the old header costs nothing. A browser that has never learned frame-ancestors reads X-Frame-Options and gets the same protection, and a browser that knows both ignores the old one. Sending X-Frame-Options: SAMEORIGIN alongside frame-ancestors 'self' is the ordinary belt-and-braces arrangement. The one mistake is sending the two with meanings that disagree, since which one wins then depends on the age of the browser.

The frame-ancestors count above is the CSP half of that arrangement.

Replacing unsafe-inline with a nonce or a hash

Take the keyword back out and give the browser a way to tell your inline script from an attacker’s. There are two ways to do that, and which one fits depends on whether your pages are rendered per request.

A nonce is a random value you generate for each response, put in the header, and put on each script tag you wrote:

Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-r4nd0mBase64'; frame-ancestors 'self'; upgrade-insecure-requests
<script nonce="r4nd0mBase64">initApp();</script>

The browser runs the script tags carrying the matching nonce and refuses the rest. An attacker can’t guess a value that changes on each response, and anything they inject arrives without it. The cost is that the nonce has to be fresh on each response and has to reach the template, which is why a static file server can’t do this and a page served from a cache shouldn’t.

A hash suits the static case. You take the SHA-256 of the script’s exact contents, encode it as base64, and put that in the header instead:

script-src 'self' 'sha256-<base64 of the script body>'

The script may then be inline, and only that exact text may be inline; change one character, including whitespace, and the hash stops matching. Hashes don’t cover inline event handlers like onclick= unless you add ‘unsafe-hashes’, and the cleaner move is to replace those with addEventListener calls in a file you serve from ‘self’.

Before you take ‘unsafe-inline’ out of the live header, send the strict policy under a different name:

Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self' 'nonce-r4nd0mBase64'; frame-ancestors 'self'; report-uri /csp-reports

Report-Only enforces nothing. The browser evaluates the page against it, runs everything as before, and posts a JSON report to the report-uri for each line it would have refused. Run it for a week alongside the header you have now and you get a list of the inline scripts still in your templates, which is the list of things to nonce, before a single visitor sees a broken page. Reporting is the part of the header that makes the rest safe to tighten.

When the reports stop, rename the header back to Content-Security-Policy, keep frame-ancestors and X-Frame-Options where they are, and watch the console for a violation you didn’t write, which is the line the policy was there to catch.

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.