# JSONP cross-origin data leak: how to check your API for it

Published: 2026-11-12 · Updated: 2026-11-03 · By HackerGPT Team
Canonical: https://hackergpt.org/blog/jsonp-cross-origin-data-leak/

> An API endpoint that wraps its JSON in a callback lets any website read it with a script tag, using the visitor’s own session. Learn how the leak works, how to check your own API for JSONP support, and how to replace it with CORS.

**TL;DR:** JSONP is an old way to share data across domains: the server wraps JSON in a function call named by a `callback` parameter, and any web page can load it with a `<script>` tag. If that endpoint returns data for the signed-in user, another site the user visits can read it, because the browser attaches the user's cookies to the script request. Check your API by requesting endpoints with `?callback=probe` and looking for a JavaScript response, then remove JSONP and use CORS with an explicit list of allowed origins.

This article is for learners studying how browsers separate websites, and for developers maintaining APIs that are old enough to have JSONP in them. It explains the leak, shows the behaviour on [CyberGym](https://cybergym.hackergpt.app) (HackerGPT's hosted OWASP Juice Shop, a legal practice target), and gives you checks for your own API. Run them only against systems you own or have written permission to test.

The video is our run of CyberGym's "Email Leak" challenge, a five-star challenge in the Sensitive Data Exposure category.

[Watch on YouTube: CyberGym Challenge: Email Leak](https://www.youtube.com/watch?v=hcWUmnjP7mU)

## What is a JSONP data leak?

It is personal or session-specific data returned in a form that any website can read, because the response is executable JavaScript rather than plain JSON. The browser's [same-origin policy](https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy) stops a page on one site from reading responses from another, but it has always allowed a page to *run* scripts from another site. JSONP uses that exception: the response `grab({"email":"…"})` calls a function the including page defined, so the data lands in that page.

The weakness is information exposure, [CWE-359: Exposure of Private Personal Information to an Unauthorized Actor](https://cwe.mitre.org/data/definitions/359.html), which the [OWASP Top 10:2025](https://owasp.org/Top10/2025/A01_2025-Broken_Access_Control/) places under A01 Broken Access Control. Researchers call the wider family **cross-site script inclusion** (XSSI); a [2015 USENIX Security paper by Lekies et al.](https://www.usenix.org/conference/usenixsecurity15/technical-sessions/presentation/lekies) studied how often dynamic, user-specific scripts leaked data this way.

## Why does JSONP still show up in real apps?

Because it was the standard answer before CORS, and frameworks still support it. Express, for example, has a built-in [`res.jsonp()`](https://expressjs.com/en/5x/api/response/#resjsonp) that switches to a callback-wrapped response whenever the request has a `callback` query parameter. In our experience JSONP survives in:

- endpoints written for an old widget, partner integration or legacy front end, and never removed;
- generic "return JSON or JSONP" helpers applied to every route, including ones that return user data;
- copy-pasted examples, since older tutorials for cross-domain requests use it.

![Diagram: a JSONP response runs inside any page that includes it, while CORS lets the server refuse other origins](https://hackergpt.org/blog/jsonp-cross-origin-data-leak/jsonp-vs-cors.webp) *With JSONP the response is a script and runs wherever it is included; with CORS the browser asks your server which origins may read it.*

The diagram also shows why CORS is the safer replacement. With [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS), the server names the origins allowed to read a response, and a credentialed request is never readable through a wildcard `Access-Control-Allow-Origin: *`. A JSONP response has no such gate.

## How did the Email Leak challenge show it?

The tester started with traffic, not payloads. The hints said to find an endpoint that responds with user information, that SQL injection was not the answer, and that the challenge used an old, no-longer-recommended way of accessing data across domains. With Burp Suite proxying the browser, the tester filtered out static assets and read what was left of the API calls. One endpoint stood out by name: it returns the currently signed-in user. After logging in to a practice account, the tester sent that request to Repeater and saw the account's email, user ID and last login IP in the response.

The old cross-domain method in the hint is JSONP, so the tester added `callback=admin` to the request. The response came back wrapped in a function call, `admin({"user":{…}})`: the same user data, now in a format any other origin could load with a script tag while the user is logged in. Opening that URL in the logged-in browser showed the same wrapped response. The full solution is in the [OWASP Juice Shop companion guide](https://pwning.owasp-juice.shop/companion-guide/latest/part2/sensitive-data-exposure.html) and its [solutions appendix](https://pwning.owasp-juice.shop/companion-guide/latest/appendix/solutions.html). In the second half of the video, HackerGPT's CTF Mode is given the same challenge text and tries callback parameters on the app's endpoints, `/rest/user/whoami?callback=` among them. Its endpoint map of the same app, which lists this endpoint, is in the [CTF Mode field guide](https://hackergpt.org/docs/ctf/#ch1).

### What we saw on CyberGym

We made the same two requests **without logging in**, so the response holds no one's data, and looked at how the format changes. Plain request, on 2 November 2026:

```bash
curl -s -D - https://cybergym.hackergpt.app/rest/user/whoami | grep -iE '^(HTTP|content-type|access-control)|^\{'
# HTTP/1.1 200 OK
# Content-Type: application/json; charset=utf-8
# Access-Control-Allow-Origin: *
# {"user":{}}
```

The same request with a callback parameter:

```bash
curl -s -D - 'https://cybergym.hackergpt.app/rest/user/whoami?callback=demo' | grep -iE '^(HTTP|content-type)|demo'
# HTTP/1.1 200 OK
# Content-Type: text/javascript; charset=utf-8
# /**/ typeof demo === 'function' && demo({"user":{}});
```

The content type switches from JSON to JavaScript and the body becomes a function call: that is Express's JSONP format. With no session the user object is empty. In the challenge, a logged-in browser fills it with the account's details, and a page on another domain can collect them. Juice Shop's [source](https://github.com/juice-shop/juice-shop/blob/master/routes/currentUser.ts) shows the cause: the handler calls `res.jsonp()` whenever a `callback` parameter is present.

Note the `Access-Control-Allow-Origin: *` on the plain response. It looks permissive, but browsers will not let another origin read a cookie-authenticated response through a wildcard, so it is JSONP, not that header, that opens the data to other sites.

## How do I check my own API for JSONP?

Ask each endpoint for a callback and see whether the answer turns into JavaScript. Run these against your own domain (`your-app.example`) or `localhost`.

**1\. Probe the endpoints that return user data.** Start with "current user", profile, settings and account endpoints, then extend to everything in your API map:

```bash
for p in /api/me /api/user /api/profile /api/account; do
  for c in callback jsonp cb; do
    printf '%-14s %-9s ' "$p" "$c"
    curl -s -o /dev/null -w '%{content_type}\n' "https://your-app.example$p?$c=probe"
  done
done
```

`application/json` is what you want. `text/javascript` or `application/javascript` means the endpoint speaks JSONP. Then repeat one request with your session cookie (copy it from your browser's developer tools) and read the body: if it holds personal data inside `probe(...)`, it is a finding.

**2\. Search the code.** JSONP support is usually one call or one option:

```bash
grep -rnE 'res\.jsonp|jsonp callback name|callback=\?|JSONP|jsonp\s*[:(]' \
  --include='*.js' --include='*.ts' --include='*.py' --include='*.php' --include='*.java' \
  src/ server/ app/ 2>/dev/null
```

**3\. Check your cookies.** Look at the `Set-Cookie` header of your login response. A session cookie with `SameSite=Lax` or `Strict` is not sent on a cross-site script request ([MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie)); `SameSite=None` is. Chrome treats a cookie with no `SameSite` attribute as `Lax` ([Chromium](https://www.chromium.org/updates/same-site/)), but do not rely on one browser's default.

To find every endpoint to probe, start from a map of your API; [how to map a web app's attack surface](https://hackergpt.org/blog/map-web-app-attack-surface/) covers that, and [how to pentest your own website](https://hackergpt.org/blog/how-to-pentest-your-own-website/) puts it in a full read-only test.

## How do I fix a JSONP leak?

Remove JSONP from any endpoint that returns user-specific data, and use CORS for legitimate cross-origin readers.

1. **Return plain JSON.** Replace `res.jsonp()` with `res.json()`, or the equivalent in your framework, and remove generic "JSON or JSONP" helpers.
2. **Allow cross-origin reads with CORS, by name.** List the exact origins that need the data and only allow credentials for those. Never reflect the request's `Origin` back unchecked.
3. **Set `SameSite` on session cookies.** `Lax` or `Strict` keeps them off cross-site subresource requests as a second layer.
4. **Serve JSON as JSON.** Keep `Content-Type: application/json` and `X-Content-Type-Options: nosniff` so a browser will not execute a data response as a script.
5. **If a public JSONP feed must stay,** make sure it ignores cookies and returns only data meant for everyone.

The [OWASP AJAX Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/AJAX_Security_Cheat_Sheet.html) covers related rules for JSON responses. These checks find JSONP on the endpoints you probe; they do not cover other cross-origin leaks such as a CORS policy that trusts any origin, which deserve their own test.

## Key takeaways

- Request each user-data endpoint with `?callback=probe`; a JavaScript content type means it supports JSONP.
- Any JSONP endpoint that returns signed-in data can be read by other sites the user visits; remove it.
- Use CORS with an explicit origin list for legitimate cross-origin access.
- Set `SameSite=Lax` or `Strict` on session cookies as a second layer, not as the fix.
- Search your code for `res.jsonp` and generic JSONP helpers before they spread to new routes.

Practise reading API traffic on [CyberGym](https://cybergym.hackergpt.app), or run [CTF Mode](https://hackergpt.org/features/ctf-mode/) on your own site to have each request and response explained.

## FAQ

### Is JSONP still used?

Rarely in new code, since CORS is supported by all current browsers and is the recommended way to share data across origins. It survives in older APIs, widgets and frameworks that still offer it as an option, which is why it is worth checking for.

### Why is JSONP a security risk?

A JSONP response is a script, and any web page can include a script from any domain. If the endpoint returns data for the signed-in user, the browser sends that user's cookies with the request and the including page receives the data through its own callback function.

### What is the difference between JSONP and CORS?

JSONP works around the same-origin policy by using script tags, with no way for the server to limit which sites read the result. CORS is a browser mechanism where the server lists the origins allowed to read a response, so it can permit trusted sites and refuse the rest.

### Does SameSite on cookies stop JSONP leaks?

It helps: cookies marked `SameSite=Lax` or `Strict` are not sent on cross-site script requests, so the endpoint sees an anonymous user. But a cookie set with `SameSite=None`, or a token sent another way, brings the leak back, so remove JSONP as well.

## Sources

- [Same-origin policy](https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy) and [Cross-Origin Resource Sharing (CORS)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS), MDN
- [Set-Cookie: SameSite](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie), MDN, and [SameSite updates](https://www.chromium.org/updates/same-site/), Chromium
- [CWE-359: Exposure of Private Personal Information to an Unauthorized Actor](https://cwe.mitre.org/data/definitions/359.html), MITRE
- [A01:2025 Broken Access Control](https://owasp.org/Top10/2025/A01_2025-Broken_Access_Control/), OWASP Top 10:2025
- [res.jsonp()](https://expressjs.com/en/5x/api/response/#resjsonp), Express API reference
- [The Unexpected Dangers of Dynamic JavaScript](https://www.usenix.org/conference/usenixsecurity15/technical-sessions/presentation/lekies), Lekies et al., USENIX Security 2015
- [AJAX Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/AJAX_Security_Cheat_Sheet.html), OWASP
- [routes/currentUser.ts](https://github.com/juice-shop/juice-shop/blob/master/routes/currentUser.ts), OWASP Juice Shop source
- [OWASP Juice Shop companion guide: Sensitive Data Exposure](https://pwning.owasp-juice.shop/companion-guide/latest/part2/sensitive-data-exposure.html) and [challenge solutions](https://pwning.owasp-juice.shop/companion-guide/latest/appendix/solutions.html)
