GuidesJSONP
JSONP vs CORS: how JSONP works and how to replace it
JSONP loads data from another origin through a <script> tag: you pass a callback name, the server wraps the JSON in a call to that function, and because scripts may load from anywhere, no CORS check happens. CORS replaced it. Of 16 public APIs we tested on 26 October 2026, 12 still answer a callback, but 14 send CORS headers, so fetch() works for most of them and a proxy covers the rest.
How JSONP works
The page adds <script src="https://api.example.com/items?callback=handle">. The server answers JavaScript instead of JSON, handle({...}), and the browser runs it, which calls your function with the data.
Express 5.3.0 shows the server side. With ?callback=handle, res.jsonp() sends Content-Type: text/javascript, X-Content-Type-Options: nosniff and the body /**/ typeof handle === 'function' && handle([...]);. Without the parameter it sends plain JSON. It strips unsafe characters from the name, so callback=alert(document.cookie)// became alertdocument.cookie. The Express source calls the /**/ prefix a mitigation for "Rosetta Flash JSONP abuse"; X, Wikipedia, Stack Exchange, GitHub, Vimeo and Gravatar start their answers with it too.
JSONP vs CORS: what JSONP can't do
JSONP behaved the same in Chromium 153, Firefox 155 and WebKit 26.6:
- GET only: a script tag cannot send POST, PUT or DELETE.
- No request headers: no
Authorizationheader and no custom headers. - No status codes: a 404 or 500 fires only the script
errorevent and the callback never runs. Chromium and WebKit print the status in the console, but your code cannot read it, and jQuery 4.0.0 reported a 500 as status 404. - No timeout: a request that never answered fired no event after 5,000 ms. jQuery 4.0.0 never called back without a
timeoutoption; withtimeout: 2000it failed with status 0 after about 2,001 ms. - Full page access: the answer runs as your page's own JavaScript. A malicious JSONP answer we served read
localStoragein all 3 engines. - CSP: the JSONP host must be allowed in
script-src. Withscript-src 'self' 'unsafe-inline'the script was blocked in all 3 engines.
With CORS, fetch() can use any method and headers, reads the status code, and gets the body as data instead of running it.
Why browsers block JSON loaded as a script
When an API drops JSONP or ignores your callback, it answers plain JSON, and a script tag cannot use that:
- With nosniff:
application/jsonplusX-Content-Type-Options: nosniffwas blocked in all 3 engines. Chromium fails withnet::ERR_BLOCKED_BY_ORB, Firefox logswas blocked due to MIME type ("application/json") mismatch (X-Content-Type-Options: nosniff), and WebKit logsRefused to execute ... as script because "X-Content-Type-Options: nosniff" was given. - Without nosniff: Chromium still fails with
net::ERR_BLOCKED_BY_ORB, and Firefox blocks it with the warning "A resource is blocked by OpaqueResponseBlocking". WebKit runs the JSON, throws "Unexpected token ':'", and the callback never runs.
We hit this with Open Library, which ignores callback and answers plain JSON. Our loader got the error event after about 1 second in Chromium and Firefox; in WebKit only the 2,000 ms timeout ended the call. A real JSONP body served as application/json still ran in all 3 engines, unless nosniff was set.
Which APIs still support JSONP
We sent each API a callback request and a plain JSON request. 12 of 16 still answer a callback, some with their own parameter: jsoncallback on Flickr, output=jsonp on Deezer, format=jsonp on ipify. YouTube oEmbed, Open Library, Spotify oEmbed and ipinfo.io ignore it. 14 of 16 send CORS headers on the plain JSON URL, Wikipedia and Wikidata only with origin=*; Flickr and Deezer send none.
| API | Callback parameter | JSONP answer | CORS on the JSON URL | Use instead |
|---|---|---|---|---|
| X (Twitter) oEmbed | callback | Yes, application/javascript | Echoes your origin | fetch() |
| Flickr feeds and oEmbed | jsoncallback | Yes, application/javascript | None | Proxy, nojsoncallback=1 for feeds |
| Wikipedia and Wikidata api.php | callback | Yes, text/javascript | Only with origin=* | fetch() with origin=* |
| Deezer API | output=jsonp plus callback | Yes, text/javascript | None | Proxy with output=json |
| iTunes Search API | callback | Yes, text/javascript | * | fetch() |
| SoundCloud oEmbed | callback with format=js | Yes, text/javascript | * | fetch() with format=json |
| Vimeo oEmbed | callback | Yes, application/javascript | * | fetch() |
| Gravatar profile JSON | callback | Yes, text/javascript | * | fetch() |
| Stack Exchange API | callback | Yes, application/javascript | * | fetch() |
| GitHub REST API | callback | Yes, wrapped in meta and data | * | fetch() |
| ipify | format=jsonp plus callback | Yes, application/javascript | * | fetch() with format=json |
| JSONPlaceholder | callback | Yes, Express res.jsonp format | Echoes your origin | fetch() |
| YouTube oEmbed | callback | No, plain JSON | Echoes your origin | fetch() |
| Open Library search | callback | No, plain JSON | * | fetch() |
| Spotify oEmbed | callback | No, plain JSON | * | fetch() |
| ipinfo.io | callback | Name ignored, console.log wrapper | * | fetch() |
Replace JSONP with fetch()
Pick the route by what the API sends:
- CORS headers: drop the callback, ask for JSON (
format=jsonon SoundCloud and ipify,origin=*on the Wikipedia API) and callfetch(). Most oEmbed providers work this way. - No CORS headers, GET returning text: ask for JSON (
nojsoncallback=1on Flickr feeds,output=jsonon Deezer) and prefixhttps://proxy.cors.dev/. Both returned 200 with CORS headers through the free CORS proxy, which needs no signup or key and relays text up to 1 MiB, with a 10-second deadline and 600 requests per minute per IP. - POST, custom headers or Authorization: use Pro ($5 per month for 500,000 requests) or your own backend.
For jQuery's dataType: 'jsonp', see the jQuery AJAX guide. The snippet below calls iTunes directly and Flickr through the proxy; it ran in all 3 engines.
// iTunes Search sends Access-Control-Allow-Origin: *, so plain fetch() works
const itunes = await fetch('https://itunes.apple.com/search?term=radiohead&entity=album&limit=3');
const albums = (await itunes.json()).results;
// Flickr sends no CORS headers, so we go through the proxy and ask for plain JSON
const proxy = 'https://proxy.cors.dev/';
const flickr = await fetch(
`${proxy}https://www.flickr.com/services/feeds/photos_public.gne?format=json&nojsoncallback=1&tags=cat`,
{ credentials: 'omit' },
);
if (!flickr.ok) throw new Error(flickr.headers.get('X-Cors-Error') ?? `HTTP ${flickr.status}`);
const photos = (await flickr.json()).items;
console.table([
...albums.map((album) => ({ source: 'iTunes', title: album.collectionName })),
...photos.slice(0, 3).map((photo) => ({ source: 'Flickr', title: photo.title })),
]);If you must keep JSONP for an old API, use a loader with a timeout that removes the script tag and the global callback. This one returned the Flickr feed in all 3 engines, rejected a 404 within 10 ms, and rejected a hanging request when its timeout ran out.
function jsonp(url, { param = 'callback', timeout = 10000 } = {}) {
return new Promise((resolve, reject) => {
const name = `jsonp_${Math.random().toString(36).slice(2)}`;
const script = document.createElement('script');
const cleanup = () => {
clearTimeout(timer);
script.remove();
delete window[name];
};
const timer = setTimeout(() => {
cleanup();
// A late answer still calls the global, so we leave a no-op that removes itself
window[name] = () => delete window[name];
reject(new Error(`JSONP: no answer after ${timeout} ms`));
}, timeout);
window[name] = (data) => {
cleanup();
resolve(data);
};
script.onerror = () => {
cleanup();
reject(new Error('JSONP: script failed to load, no status code available'));
};
const target = new URL(url);
target.searchParams.set(param, name);
script.src = target.href;
document.head.append(script);
});
}
// Flickr's public feed sends no CORS headers but still answers jsoncallback
const feed = await jsonp('https://www.flickr.com/services/feeds/photos_public.gne?format=json&tags=cat', {
param: 'jsoncallback',
});
console.table(feed.items.slice(0, 5).map((item) => ({ title: item.title, taken: item.date_taken })));Server side: res.jsonp versus CORS headers
On your own server, send CORS headers instead of JSONP; the Express CORS guide covers the options. With cors({ origin: 'https://app.example.com' }), Express sent Access-Control-Allow-Origin: https://app.example.com and Vary: Origin, and answered the preflight with 204 and Access-Control-Allow-Methods: GET,HEAD,PUT,PATCH,POST,DELETE.
Any page on any site can load a JSONP URL and read the answer, so a JSONP endpoint must only serve public data. With CORS, the server decides which origins may read the response.
import express from 'express';
import cors from 'cors';
const app = express();
const items = [{ id: 1, name: 'first' }, { id: 2, name: 'second' }];
// Legacy JSONP: GET /legacy/items?callback=handle answers text/javascript
app.get('/legacy/items', (req, res) => res.jsonp(items));
// CORS: fetch() from the allowed origin gets any method, the status code and the body
app.use('/api', cors({ origin: 'https://app.example.com' }));
app.get('/api/items', (req, res) => res.json(items));
app.listen(3000);Good questions.
What is the difference between JSONP and CORS?
JSONP loads data as a script with a callback, so it works without CORS headers but is limited to GET and runs the server's code in your page. CORS is a set of response headers that let fetch() read another origin's response as data, with any method, headers and status codes.
Is JSONP still used?
Less and less. 12 of the 16 public APIs we tested on 26 October 2026 still answer a callback, but 14 of the 16 also send CORS headers, so fetch() is the better choice for nearly all of them.
Why does my JSONP request fail with ERR_BLOCKED_BY_ORB?
The server answered plain JSON instead of JavaScript, usually because the API dropped JSONP or ignores your callback parameter. Chromium blocks JSON loaded as a script with net::ERR_BLOCKED_BY_ORB and Firefox blocks it too, so call the URL with fetch() instead.
Is JSONP safe?
Only as safe as the server you load it from: the answer runs as JavaScript in your page with full access, and our test answer read localStorage. On the server side, any site can read a JSONP endpoint, so it must never return private data.
Can JSONP send POST requests or headers?
No. A script tag only makes GET requests and cannot send custom headers or an Authorization header.
How do I replace JSONP with fetch()?
Remove the callback parameter, ask for JSON, and call the URL with fetch(). If the API sends no CORS headers, like Flickr or Deezer, prefix https://proxy.cors.dev/ for public GET requests or call it from your backend.