Chrome extension fetch blocked by CORS policy: why it happens and how to fix it
6 min readModerok team
Why a Chrome extension fetch is blocked by CORS policy, how host_permissions exempts the request, and what to do when the fetch is in a content script.
If your Chrome extension fetch is blocked by CORS policy, the short answer is that your extension has an origin like chrome-extension://ndkjhcbadgpjfhcgbolopdcmgfimnjge, and a request to https://api.example.com from that origin is an ordinary cross-origin request. The browser applies the normal CORS rules unless you have declared the host in host_permissions. So there are exactly two fixes: add the host to host_permissions so Chrome exempts the request, or make the server return an Access-Control-Allow-Origin header that covers your extension's origin. A content script is a separate case, because its requests carry the page's origin rather than your extension's, covered below.
The error, and what Chrome is actually telling you
The message looks like this in the DevTools console:
Access to fetch at 'https://api.example.com/v1/events' from origin
'chrome-extension://ndkjhcbadgpjfhcgbolopdcmgfimnjge' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
Two details in that line matter. First, the origin is your extension ID, not the page the user is on. Extension pages and the MV3 background service worker run in the extension's own origin, so a public API that allows https://yoursite.com still says nothing about chrome-extension://.... Your extension ID also changes between an unpacked local build and the published item, so an allowlist of extension origins on the server side is awkward to maintain.
Second, the request usually did reach the server. CORS is a browser-enforced read restriction, not a firewall: the server processed the POST and replied, and Chrome then refused to hand the response to your code. Your fetch() promise rejects with a TypeError ("Failed to fetch") and you cannot inspect the status, so a CORS failure and a real network failure look identical inside your catch block.
Fix 1: declare the host in host_permissions
Chrome grants extensions a privilege web pages do not have. For hosts listed in host_permissions, requests from an extension service worker or extension page skip the CORS check entirely, and the server need not return any CORS header:
{
"manifest_version": 3,
"name": "My extension",
"version": "1.0.0",
"host_permissions": ["https://api.example.com/*"],
"background": { "service_worker": "background.js" }
}
This is the standard fix for calling a third-party API you do not control. It has a real cost, though: host permissions are user-visible. A broad pattern produces the "read and change all your data on the websites you visit" warning in the Chrome Web Store install prompt. Keep the pattern as narrow as the API you actually call, https://api.example.com/* rather than https://*/*. We wrote about narrowing that warning in why your extension asks to read and change all your data.
Firefox complicates this. In Firefox 126 and earlier, MV3 host permissions were not granted at install and were not shown to the user, so a cross-origin fetch could fail there with a manifest that works on Chrome. From Firefox 127, host permissions in host_permissions and content_scripts are shown in the install prompt and granted on install. Users can still revoke them at any time, so defensive code should call chrome.permissions.contains() before assuming access, or request it at runtime with chrome.permissions.request().
Fix 2: send CORS headers from the server
If you own the endpoint, the cleaner option is to answer with permissive CORS headers and skip host_permissions altogether. Every extension has a different ID, so allowlisting extension origins does not scale. A wildcard works for anonymous, unauthenticated requests:
Access-Control-Allow-Origin: *
This is the approach the Moderok ingestion API takes, which is why the Moderok SDK manifest guide lists only "permissions": ["storage"] and no host_permissions at all. Nothing about that is special to Moderok. It is the normal arrangement for a telemetry endpoint: Google's own Google Analytics sample extension and PostHog's browser extension guide both declare "permissions": ["storage"] with no host_permissions either, though PostHog additionally asks you to allow https://*.posthog.com in an extension_pages content security policy.
One caveat: Access-Control-Allow-Origin: * is incompatible with credentialed requests. fetch() defaults to credentials: 'same-origin', so cookies are not sent cross-origin unless you explicitly pass credentials: 'include'. If you do, the server must echo the exact origin and add Access-Control-Allow-Credentials: true. A wildcard plus credentials is rejected by the browser.
The preflight trap: Content-Type: application/json
Even with correct CORS headers, an OPTIONS request often fails before the POST is attempted. That is a preflight. A request avoids preflight only if it is a "simple" request, which among other conditions means the Content-Type is one of text/plain, application/x-www-form-urlencoded, or multipart/form-data. Any other value, application/json included, triggers a preflight OPTIONS that the server must answer with the right Access-Control-Allow-Methods and Access-Control-Allow-Headers.
Custom headers do the same thing. An Authorization or X-Api-Key header is not CORS-safelisted, so it forces a preflight no matter what content type you use.
If you control the server and want to avoid the extra round trip, send a JSON body under a simple content type and parse it server-side. That is what the Moderok SDK's transport does:
await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "text/plain;charset=UTF-8" },
body: JSON.stringify(payload),
});
The body is still JSON. Only the declared content type changes, which keeps the request "simple" and removes one OPTIONS round trip per flush. Do this only when the server is yours and will parse a text/plain body.
Content scripts cannot fix this with host_permissions
If the failing fetch() lives in a content script, Fix 1 does not help. A content script runs in the web page's origin, so its requests are attributed to that page, not to your extension, and your extension's host_permissions do not exempt them. Fix 2 does still apply: the request carries the page's origin in its Origin header, so a server answering with a matching Access-Control-Allow-Origin is honored, and a content script posting to an endpoint that returns a wildcard works fine. Only servers you cannot change leave you stuck. Chrome tightened this deliberately. The Chrome extensions documentation puts it plainly: content scripts have been subject to Cross-Origin Read Blocking since Chrome 73 and to CORS since Chrome 83 (the change was announced for 83 and reached Stable in 85). The rationale was that a compromised renderer process should not be able to use an extension's host permissions to read arbitrary cross-origin data.
When the server will not send a header you can use, the supported pattern is to relay the request through the background service worker, which does have the extension's privileges:
// content-script.js (a classic script, so no top-level await)
chrome.runtime.sendMessage({ type: "fetchStats" }).then((data) => {
render(data);
});
// background.js (service worker)
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type !== "fetchStats") return;
fetch("https://api.example.com/v1/stats")
.then((r) => r.json())
.then(sendResponse)
.catch((err) => sendResponse({ error: String(err) }));
return true; // keep the message channel open for the async response
});
That return true is load-bearing. Without it the channel closes before sendResponse runs and you get a different error entirely, which we covered in the message port closed before a response was received.
Where to look when you cannot see the error
CORS errors from the service worker do not appear in the page's console. Open chrome://extensions, enable Developer mode, and click the "service worker" link on your extension's card to get a DevTools window scoped to the worker. Remember the worker can be terminated while idle, so if the console is empty, trigger the code path again to wake it.
The console message tells you which failure mode you hit, and the tell is the prefix, not the rest of the line. If it begins with "Response to preflight request doesn't pass access control check", the OPTIONS round trip failed and your POST never ran, so look at your Content-Type and any custom headers first. Without that prefix, the actual request is what got blocked. The reason that follows, most often "No 'Access-Control-Allow-Origin' header is present on the requested resource", is emitted in both cases, so read it as what the server failed to send rather than as which request failed. For the raw requests as they went out on the wire, including the OPTIONS, use your server logs or chrome://net-export.
If what you are sending is product analytics rather than an API call you control, the whole problem is avoidable. Moderok's SDK posts to an endpoint that returns permissive CORS headers and uses a simple content type, so it needs no host_permissions and adds no install warning. The manifest guide shows the full set of permissions it needs, which is one.