Chrome extensions live in a weird middle ground. They’re not normal web pages, but they’re not fully trusted native apps either. That matters a lot for CORS.

If you build extensions long enough, you hit this fast:

  • fetch() works in your background service worker
  • the same request fails in a content script
  • adding Access-Control-Allow-Origin to your request does nothing
  • people tell you to “just use host_permissions”
  • and then preflights still surprise you

The mental model that has saved me the most time is this:

A Chrome extension has multiple execution contexts, and CORS rules depend heavily on which one makes the request.

That’s the whole game.

The three places requests usually come from

In Manifest V3, you’ll typically make requests from one of these places:

  1. Extension pages — popup, options page, side panel, or any HTML page inside the extension
  2. Background service worker — the extension’s long-lived logic
  3. Content scripts — JavaScript injected into a website

These do not behave the same way.

Extension pages and service workers

These run in the extension origin, something like:

chrome-extension://<extension-id>

If you declare the right host_permissions, these contexts can fetch cross-origin resources that a normal page could not.

Content scripts

Content scripts are the trap. They run on web pages and interact with the DOM, but network behavior is constrained by the page’s origin in ways that surprise people.

Even though the script came from your extension, the browser still treats many requests like they’re being made from the page context. So if the page couldn’t call that API due to CORS, your content script often can’t either.

If you need reliable cross-origin API access, I almost always move the request to the background service worker and message the result back.

The extension manifest you actually need

Here’s a minimal Manifest V3 example:

{
  "manifest_version": 3,
  "name": "CORS Demo Extension",
  "version": "1.0.0",
  "permissions": ["storage"],
  "host_permissions": [
    "https://api.github.com/*"
  ],
  "background": {
    "service_worker": "background.js"
  },
  "action": {
    "default_popup": "popup.html"
  },
  "content_scripts": [
    {
      "matches": ["https://example.com/*"],
      "js": ["content.js"]
    }
  ]
}

The key part is:

"host_permissions": [
  "https://api.github.com/*"
]

Without that, your extension service worker or popup won’t get far with cross-origin fetches.

A simple cross-origin fetch from the background service worker

background.js:

chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.type === "github-user") {
    fetch("https://api.github.com/users/octocat", {
      headers: {
        "Accept": "application/vnd.github+json"
      }
    })
      .then(async (response) => {
        const data = await response.json();

        sendResponse({
          ok: response.ok,
          status: response.status,
          headers: {
            etag: response.headers.get("etag"),
            link: response.headers.get("link"),
            rateLimitRemaining: response.headers.get("x-ratelimit-remaining")
          },
          data
        });
      })
      .catch((error) => {
        sendResponse({
          ok: false,
          error: error.message
        });
      });

    return true;
  }
});

And from the popup:

document.getElementById("load").addEventListener("click", async () => {
  const result = await chrome.runtime.sendMessage({ type: "github-user" });

  console.log(result);

  if (!result.ok) {
    document.body.textContent = `Request failed: ${result.error || result.status}`;
    return;
  }

  document.body.textContent = JSON.stringify(result.data, null, 2);
});

This is the pattern I recommend most:

  • UI asks background to fetch
  • background performs the cross-origin request
  • background returns structured data

It keeps your security model cleaner and avoids content-script CORS headaches.

Real-world CORS headers: GitHub’s API

When you call https://api.github.com, the response includes:

access-control-allow-origin: *
access-control-expose-headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset, Warning

That second header matters more than most developers realize.

By default, JavaScript can only read a small safelisted set of response headers. If an API wants your code to read custom headers like ETag or X-RateLimit-Remaining, it must expose them with Access-Control-Expose-Headers.

That’s why this works against GitHub:

const response = await fetch("https://api.github.com/rate_limit");
console.log(response.headers.get("x-ratelimit-remaining"));
console.log(response.headers.get("etag"));

If the server didn’t expose those headers, get() would return null even though the header exists in the actual HTTP response.

Why your content script request fails

Say you inject this into a page:

fetch("https://api.github.com/user")
  .then((r) => r.json())
  .then(console.log)
  .catch(console.error);

That may fail because:

  • the page origin is not allowed by the API’s CORS policy
  • credentials or custom headers trigger preflight rules
  • the request is treated with the page’s web security restrictions

The fix is usually not “fight harder with fetch options.” The fix is architectural:

  1. content script sends a message to the background
  2. background performs the request
  3. content script receives the result

content.js:

async function getGitHubRateLimit() {
  const result = await chrome.runtime.sendMessage({
    type: "github-rate-limit"
  });

  if (!result.ok) {
    throw new Error(result.error || `HTTP ${result.status}`);
  }

  return result;
}

getGitHubRateLimit()
  .then((result) => {
    console.log("Rate limit:", result.headers.remaining);
    console.log(result.data);
  })
  .catch(console.error);

background.js:

chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.type === "github-rate-limit") {
    fetch("https://api.github.com/rate_limit")
      .then(async (response) => {
        const data = await response.json();

        sendResponse({
          ok: response.ok,
          status: response.status,
          headers: {
            remaining: response.headers.get("x-ratelimit-remaining"),
            reset: response.headers.get("x-ratelimit-reset"),
            resource: response.headers.get("x-ratelimit-resource")
          },
          data
        });
      })
      .catch((error) => sendResponse({ ok: false, error: error.message }));

    return true;
  }
});

Preflights still exist

Extensions don’t magically erase HTTP semantics.

If your request uses:

  • PUT, PATCH, DELETE
  • Content-Type: application/json in some contexts
  • custom headers like Authorization or X-Whatever

the browser may send an OPTIONS preflight first.

Example:

await fetch("https://api.example.com/items/123", {
  method: "PATCH",
  headers: {
    "Content-Type": "application/json",
    "Authorization": "Bearer token"
  },
  body: JSON.stringify({ name: "Updated" })
});

For that to work, the server still needs to answer the preflight with the right headers, such as:

Access-Control-Allow-Origin: chrome-extension://<extension-id>
Access-Control-Allow-Methods: PATCH, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization

Or a compatible policy depending on the endpoint.

This is where teams get confused: host permissions help your extension initiate the request, but they do not force the server to accept unsafe cross-origin methods and headers.

The server still has a vote.

Don’t try to set CORS response headers yourself

This never works the way people hope:

fetch("https://api.example.com/data", {
  headers: {
    "Access-Control-Allow-Origin": "*"
  }
});

Access-Control-Allow-Origin is a response header. The server sends it. Clients do not grant themselves permission by adding it to requests.

Same story for:

  • Access-Control-Allow-Headers
  • Access-Control-Allow-Methods
  • Access-Control-Expose-Headers

If you don’t control the server, you can’t “fix” its CORS policy from extension JavaScript.

Credentials and cookies

If your extension needs authenticated API calls, think carefully about whether you’re using:

  • bearer tokens in headers
  • cookies
  • the chrome.identity API
  • the browser’s cookie jar

A credentialed CORS request is stricter than a public one. Wildcard origin rules often stop being valid when credentials are involved.

For example, this combination is invalid for credentialed requests:

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

If you rely on cookies, the server usually needs a specific allowed origin, not *.

That’s one reason token-based API auth is often simpler for extensions than session-cookie auth.

Reading response headers safely

A lot of extension code ignores headers and just grabs JSON. That’s a mistake. Headers often carry the operational data you actually need:

  • ETag for caching
  • Link for pagination
  • Retry-After for backoff
  • X-RateLimit-Remaining for quota handling

GitHub exposes all of these through CORS, which is honestly a good model.

Example:

const response = await fetch("https://api.github.com/user/repos?per_page=30");

const repos = await response.json();
const nextPage = response.headers.get("link");
const etag = response.headers.get("etag");
const remaining = response.headers.get("x-ratelimit-remaining");

console.log({ etag, remaining, nextPage, reposCount: repos.length });

If you’re building a serious extension, this is the difference between “works on my machine” and “survives actual usage.”

Security advice I’d actually enforce

A few rules I stick to:

  • Keep cross-origin fetches in the background service worker when possible
  • Request the smallest possible host_permissions
  • Never proxy arbitrary user-supplied URLs without validation
  • Treat content scripts as less trusted than extension pages
  • Be careful with tokens stored in extension storage
  • Review CSP alongside CORS if your extension renders remote data; if you need a refresher on broader header hardening, https://csp-guide.com is a good companion resource

That third point matters a lot. An extension with broad host permissions can become a powerful cross-origin proxy. If a content script or popup can tell your background worker to fetch any URL, you may accidentally build an SSRF-like primitive inside the browser.

Validate destinations. Whitelist hosts. Be strict.

The practical takeaway

If you remember only one pattern, use this one:

  • content script handles page interaction
  • background service worker handles cross-origin API requests
  • host_permissions declare which hosts are allowed
  • server CORS policy still matters, especially for preflighted requests and credentials
  • exposed headers determine what your extension can read from the response

That’s the version of Chrome extension CORS that matches reality, not forum folklore.

For the official details, check Chrome extension docs and the Fetch/CORS documentation from the platform vendors.