Making Cross-Origin Fetch Requests from an Extension
Fix CORS errors in an MV3 extension: why the service worker is exempt with host_permissions, why content scripts are not, how to proxy through the worker, and what Firefox and Safari do differently.
Table of Contents
The console shows “Access to fetch at ‘https://api.acme.example/v1/lookup' from origin ‘https://news.site’ has been blocked by CORS policy”. The same request works perfectly from the popup. Or the reverse: the content script works in Firefox and fails in Chrome. Both are the result of one rule that changed during the MV3 era and that most older tutorials predate: extension contexts with host permission are exempt from CORS, but content scripts are not extension contexts for this purpose — they borrow the origin of the page they run in. This guide belongs to network requests and backend sync.
Why the same fetch behaves differently by context
CORS is enforced on behalf of an origin. The service worker, popup, options page and side panel have the extension’s own origin, chrome-extension://<id>, and Chrome grants that origin cross-origin access to every host listed in host_permissions — no preflight, no Access-Control-Allow-Origin needed from the server. A content script also has host permissions in a sense, but since Chrome 85 its network requests are made as the page: the request carries the page’s Origin header, cookies follow the page’s rules, and the response is readable only if the server’s CORS headers allow the page’s origin. Chrome made this change because content scripts run inside pages that may be compromised, and a compromised renderer could otherwise use a content script’s privileges to read any site the extension can reach.
Step-by-step: make every cross-origin call work
1. Declare the API host
1{
2 "host_permissions": ["https://api.acme.example/*"]
3}
Execution context: the manifest. This grants the extension origin CORS-free access to the API from the worker and extension pages. It does nothing for content scripts in Chrome. The match pattern must include the scheme and a path wildcard; https://api.acme.example without /* is not a valid pattern and the manifest fails to load.
2. Confirm which context is failing
Before changing code, check where the failing request originates. In DevTools’ Network panel, the request’s “Initiator” column and its Origin request header tell you immediately.
1// Paste into the console of each context to compare
2const r = await fetch("https://api.acme.example/v1/health");
3console.log(location.origin, r.status, r.headers.get("access-control-allow-origin"));
Execution context: run once in the service worker console and once in the page console with the content script’s isolated world selected in the context dropdown. The worker should print the extension origin and a 200 regardless of the CORS header. The content script prints the page’s origin and throws a TypeError if the API does not allow that origin.
3. Move the request into the worker
1// content-script.js
2async function lookup(term) {
3 const reply = await chrome.runtime.sendMessage({ type: "lookup", term });
4 if (!reply?.ok) throw new Error(reply?.error ?? "lookup failed");
5 return reply.data;
6}
1// sw.js
2const OPS = {
3 async lookup({ term }) {
4 if (typeof term !== "string" || term.length === 0 || term.length > 200) throw new Error("bad term");
5 const res = await fetch(`https://api.acme.example/v1/lookup?q=${encodeURIComponent(term)}`);
6 if (!res.ok) throw new Error(`HTTP ${res.status}`);
7 return res.json();
8 },
9};
10
11chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
12 const op = Object.hasOwn(OPS, msg?.type) ? OPS[msg.type] : null;
13 if (!op || !sender.tab) return;
14 op(msg).then((data) => sendResponse({ ok: true, data }),
15 (err) => sendResponse({ ok: false, error: err.message }));
16 return true;
17});
Execution context: the content script sends from the page’s isolated world; the worker performs the fetch with extension privileges. The worker accepts a fixed set of operations with validated arguments, never a raw URL — a “fetch this for me” message would turn your extension into an open proxy for any page that compromises its renderer. The sender.tab check ensures the message came from a content script rather than from another extension page.
4. Keep same-origin requests in the content script
Requests to the page’s own origin — reading the current user’s data from the site’s API to enhance its UI — are not cross-origin at all and work from the content script, with the page’s cookies.
1// content-script.js on https://app.example.com
2const me = await fetch("/api/me", { credentials: "include" }).then((r) => r.json());
Execution context: the content script in the isolated world, whose fetches behave like the page’s. This is often exactly what you want: the request carries the user’s session cookies for the site, and the site’s server sees an ordinary same-origin request. Making the same call from the worker would require host permission for the site and would send cookies according to the worker’s (third-party) context, which under modern cookie rules may mean none at all.
5. Set credentials deliberately from the worker
1// Worker request that should NOT carry the user's cookies for that site
2await fetch("https://api.acme.example/v1/public", { credentials: "omit" });
3
4// Worker request to your API using a bearer token instead of cookies
5await fetch("https://api.acme.example/v1/me", {
6 headers: { Authorization: `Bearer ${await getAccessToken()}` },
7});
Execution context: the service worker. With host permission, Chrome attaches cookies for the target host to worker requests by default, subject to SameSite — which surprises teams who expected an extension request to be anonymous. Prefer explicit bearer tokens for your own API; they work identically in every engine and do not depend on third-party cookie policy.
Cross-browser variation
- Chrome / Edge: worker and extension pages are CORS-exempt with host permission; content scripts follow page CORS since Chrome 85. Chrome may still send a preflight for some requests from extension pages but will not enforce the response’s CORS headers.
- Firefox: content scripts with host permission still get extension privileges for cross-origin fetches, so Chrome-only failures are common. Firefox also offers
content.fetch()for requests made explicitly as the page. Extension origins aremoz-extension://<random-uuid>and differ per install, which is another reason not to allow-list the extension origin on your server. - Safari: follows Chrome’s model — page CORS for content scripts — and requires the user to have granted the relevant host permission, which in Safari is per-site and often “ask each time”.
Verification
- Load the page with the content script and open its DevTools Network panel. Trigger the feature.
- Confirm there is no request to
api.acme.exampleinitiated by the page or content script. - Open the service worker DevTools Network panel and confirm the request appears there, with status 200 and an
Originofchrome-extension://…(or noOriginat all for a simple GET). - Load the same build in Firefox and Safari and confirm identical behaviour.
1Service worker → GET https://api.acme.example/v1/lookup?q=alarms 200 (from: sw.js)
Execution context: the service worker’s DevTools. If the request still appears in the page’s panel, some code path is fetching directly from the content script.
FAQ
Can I just add Access-Control-Allow-Origin: * to my API?
For public, unauthenticated endpoints, yes, and content scripts can then call them directly. For anything carrying user credentials, a wildcard is unsafe and * is not allowed alongside credentials anyway. Proxying through the worker avoids the question.
Do extension pages need host permissions to call my API?
Without host permissions they are ordinary cross-origin requests from chrome-extension://<id> and need your server’s CORS headers to allow that origin. With host permissions they are exempt.
Why does the request work in development but not after publishing?
Usually because development used an unpacked build with broader host permissions, or because a server CORS allow-list included the unpacked extension’s id. Store builds have a different id unless pinned.
Related
- Syncing extension data with a backend API — durable writes from the worker.
- Wrapping message passing in promises — tidier content-script-to-worker calls.
- Treating content script messages as untrusted — why the worker validates operations.
- Network requests and backend sync — the parent topic.