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.

Published October 2, 2026 Updated October 2, 2026 7 min read
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.

The same request from two contextsA content script's fetch carries the page origin and is blocked by the API's CORS policy; the service worker's fetch carries the extension origin, is exempt with host permission, and succeeds.Content scriptService workerapi.acme.examplefetch (Origin: https://news.site)no ACAO for news.site → blockedsendMessage({type:'lookup'})fetch (extension origin, host perm)200 JSON (readable)reply with data
Move the fetch, not the server configuration.

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.

Where should this request run?Decision tree for placing a network request: requests to your own API go through the worker, requests that need the page's cookies run in the page context, and same-origin requests can run in the content script.What is the request's target?your APIService workerhost permission, no CORSMessage an operationnever a raw URLpage's own originContent scriptsame-origin, no CORSPage cookies sentuser's session appliesthird partyWorker if permittedneeds that hostJustify the hostreview scrutiny
Most cross-origin calls belong in the worker; same-origin calls can stay in the content script.

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 are moz-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”.
Cross-origin fetch from a content script, by engineWhether a content script's cross-origin fetch to a host the extension has permission for succeeds in Chrome, Firefox and Safari, and which origin the server sees.AspectChromeFirefoxSafariRequest succeeds without CORS headersNoYesNoOrigin headerPage originExtension or nonePage originWorker proxy worksYesYesYes
Firefox lets it through; Chrome and Safari do not — so proxy through the worker everywhere.

Verification

  1. Load the page with the content script and open its DevTools Network panel. Trigger the feature.
  2. Confirm there is no request to api.acme.example initiated by the page or content script.
  3. Open the service worker DevTools Network panel and confirm the request appears there, with status 200 and an Origin of chrome-extension://… (or no Origin at all for a simple GET).
  4. 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.

Other Core APIs & Cross-Browser Data Management Resources