# Common Content-Security-Policy mistakes

> A Content-Security-Policy only helps if it forbids what an attacker needs. The usual ways a policy ends up permitting injected script, and how to check yours.

Published 2026-10-27 · https://aiskills402.com/blog/csp-header-mistakes

A Content-Security-Policy is a response header that tells the browser which scripts, plugins and frames the page may use. Its main job is to stop injected script from running when a site has a cross-site scripting hole, and it can also stop other sites from framing your page. It only does that job if the header forbids the things an attacker needs. A policy can look thorough, pass a casual glance and still allow all of them, so this guide goes through the habits that empty a policy of meaning and what to write instead.

## What the header really blocks

The browser reads the policy as a list of rules, one per kind of resource. The rule for scripts decides which script files may load, whether inline script blocks run, whether inline event attributes such as `onerror` work, and whether `javascript:` links work. If the policy has a script rule or a general fallback rule, all of those inline forms are blocked by default, and so is string-to-code execution such as `eval()`. That default is the useful part: an attacker who manages to put a `<script>` tag or an `onerror` attribute into your page finds that the browser refuses to run it ([MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP)).

MDN is clear that this is a second layer. It does not replace cleaning up input; it limits the damage when the cleaning fails. Keep that picture in mind, because every mistake below removes a piece of that second layer without changing how the header looks.

## Mistake 1: allowing inline script with unsafe-inline

The keyword `'unsafe-inline'` switches the inline block off again. The simplest policy copied from a tutorial is `default-src 'self' 'unsafe-inline'`, which reads as "only our own origin" but permits any inline script, including one an attacker injected. MDN's own guide advises developers to avoid this keyword, and OWASP recommends a strict policy built on nonces or hashes instead of long lists of allowed hosts ([OWASP](https://cheatsheetseries.owasp.org/cheatsheets/Content_Security_Policy_Cheat_Sheet.html)).

Two details matter when you review a header:

- A nonce is a random value created for every response and placed both in the header and in each legitimate script tag. If the script rule carries a nonce, or alternatively a digest of the script, browsers ignore `'unsafe-inline'` beside it. So `unsafe-inline` next to a nonce is only a fallback for very old browsers, not a hole.
- The same keyword inside the style rule is a smaller problem than inside the script rule. Injected styles can still hide or reshape a page, but they do not run code, so treat the two differently and do not report them as equal.

The same logic applies to `'unsafe-eval'`. It re-enables `eval()` and the string forms of `setTimeout`, and MDN advises avoiding it for the same reasons. If a library really needs it, look at Trusted Types as the safer route instead of opening the door for the whole page.

OWASP also warns about the way nonces are used: a build step that simply stamps the same marker on every script tag in the output defeats the purpose, because attacker-injected tags get stamped too. The nonce has to come from a real templating step that adds it only to your own tags.

## Mistake 2: sources that are wider than they look

A script source of `*` allows scripts from every host. A bare `https:` allows any host that uses https, which is nearly all of them. A `data:` source in the script rule lets an attacker build a script address out of text. None of these look dangerous in a quick read, because the list is not empty and the word `script-src` is there.

Host lists have a quieter version of the same problem. If you allow a whole CDN or a host that serves user-supplied files, anything on that host counts as your own script. OWASP describes the older allow-list approach as harder to get right than the strict approach and points to published research on how such lists get bypassed. The practical advice is to prefer a nonce with `'strict-dynamic'`: trust then flows from your marked script to the scripts it loads, and the browser ignores the host list.

## Mistake 3: forgetting the directives that have no fallback

A fallback rule called `default-src` covers many resource types, which makes people think it covers everything. It does not cover three useful ones: which URLs the `<base>` element may use, where forms may submit, and who may frame the page. Each of them has to be spelled out on its own. OWASP's strict example includes the two rules most often left out, shown here in a different order with the nonce rule last:

```
Content-Security-Policy: base-uri 'none'; script-src 'nonce-k3j9x'; object-src 'none'
```

- **object-src 'none'** blocks plugin content such as `<object>` and `<embed>`. Without it, and without a fallback rule, an injected plugin element is not stopped by the script rule.
- **base-uri 'none'** blocks an injected `<base>` tag, which would otherwise redirect every relative script address on the page to a host chosen by the attacker. This is how a policy that allows `'self'` scripts can still be beaten.
- **frame-ancestors** decides who may embed your page. MDN calls it an effective protection against clickjacking and a more flexible replacement for `X-Frame-Options`; set it to `'none'` unless the page must be embeddable. Note that it does not work from a meta tag.

## Mistake 4: report-only that never becomes enforcement

The `Content-Security-Policy-Report-Only` header sends violation reports and blocks nothing. It is a good way to learn what a strict policy would break before you turn it on. OWASP describes it as the usual precursor to blocking mode. The mistake is leaving it there. A site can run for months with a perfect-looking strict policy in the report-only header and no protection at all, because nobody went back to rename it.

Check the response of the live site, not the config in the repository, and look at the exact header name. If both headers are present, both are honoured, which is a valid way to enforce a loose policy while testing a stricter one. But the enforced one has to exist.

A related slip is delivering the policy in a meta tag. It works for most script rules, but OWASP notes that framing protection, sandboxing and a reporting endpoint are not available that way, so a static site on a CDN that cannot set headers should plan around that.

## A check that can fail

Read your live header and answer these in order:

1. Is the header name `Content-Security-Policy`, not the report-only one?
2. Does the script rule contain a nonce or hash, and no `*`, no bare scheme, no `data:`?
3. Is `unsafe-inline` or `unsafe-eval` absent from the script rule, or only present beside a nonce?
4. Are `base-uri`, `frame-ancestors` and `object-src` all present?

Then test the check itself: add a harmless inline script to a staging page without the nonce and open the console. A refusal message proves the policy is doing something. If the script runs, the header is decoration, however good it looked.

If you read many headers, the review is mechanical enough to hand to a model, but the lists above are the part to keep. Reading a file before you let it act is a neighbouring habit, and a guide on [vetting agent skills before installing](/blog/agent-skills-security-risks) applies it to a different kind of file.

## When this does not apply

The scope here is script injection and framing. It says little about a policy that is mostly about other things, such as upgrading insecure requests or controlling which images load, where an image source of `data:` is normal. A strict nonce policy needs server-rendered HTML that can add a fresh value to each response; a fully static page may have to use hashes, and a page full of third-party widgets may have to accept a looser policy and treat it as a damage limiter rather than a wall. A policy never fixes the underlying injection, so keep cleaning input and encoding output. Browser behaviour also changes between versions, so test in the browsers your readers use, and trust the current documentation over this page where they differ.

## What to do next

What a review of a policy actually does for you is concrete. It finds script rules that still permit inline code or any host. It finds the absent `frame-ancestors`, `base-uri` and `object-src` rules. It notices a report-only header described as if it enforced. And it leaves alone the look-alikes that are fine: inline styles only, an inline allowance beside a nonce, images from `data:`.

For that job we sell a skill, [CSP Header Review](https://aiskills402.com/skills/csp-header-review). You paste one header, optionally with the note that came with it, and it answers with a short list of the real gaps, one line each with the directive, the reason and the fix, or with the plain statement that it found none. The store is at https://aiskills402.com, where the skill can be bought.
