How JSONP endpoints bypass your CSP
CentralCSP Team ·
Last update:
A Content Security Policy (CSP) that allowlists a third-party host can be turned against you. If that host serves a JSONP endpoint, an attacker who can inject a <script> tag into your page gets to run arbitrary JavaScript, and the browser allows it because the host is on your list. The policy you added to stop cross-site scripting (XSS) ends up waving the attack through.
The short version: host and scheme allowlists are the weak part of CSP. The fix is to stop trusting hosts for scripts and trust nonces instead, with 'nonce-...' plus 'strict-dynamic'. The rest of this post shows how JSONP works, exactly how the bypass executes, and how to move off allowlists without breaking your site.
New to this header? Get started with Content Security Policy covers the basics first.
What is JSONP?
JSONP ("JSON with padding") is an older way to fetch data across origins. It works because the HTML <script> element is not subject to the same-origin policy: a script src can point at any domain, and the browser runs whatever JavaScript comes back.
The client and server agree on a query parameter, conventionally callback or jsonp. The server takes that value and prepends it to its response, wrapping the JSON payload in a function call so the whole response is a valid JavaScript program. The page then dynamically adds a <script> whose src is the endpoint plus the callback name:
// The client adds a script that points at a cross-origin endpoint
const script = document.createElement('script');
script.src = 'https://cdn.example.com/api?callback=handleData';
document.body.appendChild(script);
// The function the server will call back into
function handleData(data) {
console.log(data.name);
}The server responds with the callback name wrapped around the data, and the browser executes it:
handleData({"name": "John"})That request and response look like this:
GET https://cdn.example.com/api?callback=handleData
handleData({"name":"John"})The data arrives by running a function the server names for you. That is the detail that makes JSONP dangerous.
How a JSONP endpoint bypasses CSP
Picture a typical policy. You allowlist your own origin plus a CDN you trust:
Content-Security-Policy:
default-src 'self';
script-src 'self' https://cdn.example.com;The script-src directive says scripts may load from your origin and from cdn.example.com. That looks reasonable. The browser will block a script from any other host.
Now suppose cdn.example.com exposes a JSONP endpoint, and an attacker has an HTML-injection foothold on your page (a reflected or stored XSS sink that has not yet led to script execution because of your policy). The attacker injects a script tag that points at the allowlisted host:
<script src="https://cdn.example.com/api?callback=alert(document.cookie)//"></script>The endpoint reflects the callback value into the start of its response body, usually without sanitizing it, so the response becomes attacker-controlled JavaScript served from a host you trust:
alert(document.cookie)//({"name":"John"})The trailing // comments out the rest of the line so the leftover payload does not cause a syntax error. The browser runs it. CSP never objects, because the request came from an allowlisted host. The policy is satisfied and the attack succeeds.
OWASP frames the test directly: when a policy allowlists third-party domains such as CDNs, check whether those domains expose JSONP endpoints or user-controlled content, because an attacker can use callback injection to execute arbitrary JavaScript while still complying with the policy.
One caveat worth stating plainly. Many JSONP endpoints restrict the callback to valid JavaScript-identifier characters, which blocks the simplest payloads like the one above. The real risk is endpoints with insufficient callback validation, not every JSONP endpoint. The example here is illustrative; the exact payload that defeats a given filter is endpoint-specific.
Why host allowlists are the root cause
This is not a quirk of one CDN. It is the structural weakness of allowlist-based policies. If you trust a host, you trust everything that host serves, including endpoints you do not control and did not audit. A trusted tag manager is the same kind of problem from the other direction; see how attackers abuse Google Tag Manager.
The study "CSP Is Dead, Long Live CSP!" (Weichselbaum, Spagnuolo, Lekies, and Janc of Google, presented at ACM CCS 2016) measured this across real policies. It found that 94.72% of distinct policies were bypassable, 75.81% used script allowlists that let attackers bypass CSP, and 14 of the 15 most-commonly-allowlisted script hosts contained unsafe endpoints, the class that includes JSONP.
The lesson web.dev draws from this: allowlist policies often leave the page exposed to XSS because they can be bypassed in most configurations. A policy built on hosts is only as strong as the weakest endpoint on every host you list.
The fix, nonces and strict-dynamic
Stop trusting hosts for scripts. Trust a per-response nonce instead, and let 'strict-dynamic' extend that trust to the scripts your trusted scripts load.
Content-Security-Policy:
script-src 'nonce-r4nd0m' 'strict-dynamic';
object-src 'none';
base-uri 'none';You then mark your own scripts with the matching nonce:
<script nonce="r4nd0m">
// your trusted code
</script>When 'strict-dynamic' is present, supporting browsers ignore 'unsafe-inline', 'self', host-based source lists, and scheme-based source lists such as https:. Trust is granted only to scripts that carry a valid nonce or hash, and to scripts that those already-trusted scripts then create. The allowlist that the JSONP endpoint relied on no longer applies, so the bypass closes.
This matches the implementation. In Chromium, once 'strict-dynamic' is present the host and scheme entries in the source list are disregarded when the browser decides whether a script may run, so an allowlisted JSONP host no longer counts.
A few things have to be right for this to hold:
- The nonce must be unguessable, at least 128 bits, base64, and regenerated on every response. A static or predictable nonce is itself a bypass.
'strict-dynamic'only addresses the allowlist and JSONP class of bypass. It relies on the nonce staying secret and on no'unsafe-inline'or'unsafe-eval'weakening the policy, which is moot here because those keywords are ignored when'strict-dynamic'is present.- Migrate carefully. Adding the keyword can break sites that load allowlisted hosts through a plain
<script src>in markup, unless a trusted nonced loader pulls them in. The path is "nonce your own scripts and let'strict-dynamic'propagate trust," not "just add the keyword."
'strict-dynamic' is part of CSP Level 3 and widely supported across current browsers. In a browser that does not understand it, the nonce still applies and the host allowlist still applies, so the policy degrades instead of breaking outright. If you are wiring this up in a framework, CSP nonces in Next.js walks through one concrete setup.
Prefer CORS over JSONP
The deeper fix is to retire JSONP where you can. CORS provides cross-origin data sharing without executing arbitrary scripts. Browsers began shipping it around 2009 and the W3C ratified it in 2014, and it is the right tool for fetching JSON across origins today:
const res = await fetch('https://cdn.example.com/api');
const data = await res.json();The server opts in with a response header rather than handing the client a function to run:
Access-Control-Allow-Origin: https://yourdomain.comIf a JSONP endpoint has to stay, validate the callback name strictly against an allowlist of identifier characters so attacker-controlled code cannot reach the response body.
Find your exposure
The hard part in practice is knowing which allowlisted hosts on your live policy serve JSONP or other unsafe endpoints. Run your policy through the CSP evaluator to see which sources weaken it and where an allowlist leaves you open, then move the directives that matter to nonces and 'strict-dynamic'.
To go further, build a strong CSP shows how to roll a strict policy out in Report-Only first, so you can watch what would break before you enforce it.
The allowlist is only half the exposure. The other half is what those hosts actually serve on your pages today, which CentralCSP builds from CSP hash reporting: a per-site inventory of every script that ran, with its origin, so a JSONP endpoint on an allowlisted CDN shows up as a script you did not intend to load rather than as a line in a policy you assumed was safe.
Sources
- Weichselbaum, Spagnuolo, Lekies, Janc, "CSP Is Dead, Long Live CSP!" (ACM CCS 2016)
- W3C, CORS publication history
- MDN, Cross-Origin Resource Sharing (CORS)