Firefox extensions sit in a weird spot for CORS. They look like frontend code, but they also get privileged access that normal web pages do not. That mismatch trips people up all the time.
I’ve seen the same bug report more than once:
“The API works in my extension background script, but fails in the popup.”
“It works in Chrome, but Firefox blocks it.”
“Why does
fetch()behave differently depending on where I call it?”
That’s the real story with CORS for Firefox extensions. The browser gives you extra power, but only in the right context, with the right permissions, and only if you stop treating extension pages like random website JavaScript.
Here’s a practical case study based on a common extension pattern: a Firefox extension that reads GitHub repository metadata from api.github.com and shows it in a popup.
GitHub is a nice real-world target because its CORS headers are explicit. A typical 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
Those two headers matter a lot:
access-control-allow-origin: *means cross-origin reads are allowed.access-control-expose-headers: ...means JavaScript can read those non-simple response headers.
That second one is often overlooked. If your extension needs rate limit info from GitHub, you only get it because GitHub explicitly exposes those headers.
The broken version
A team built a Firefox extension popup that fetched repo details directly from GitHub. The popup script looked harmless:
async function loadRepo() {
const res = await fetch("https://api.github.com/repos/mdn/content");
const data = await res.json();
document.querySelector("#name").textContent = data.full_name;
document.querySelector("#stars").textContent = data.stargazers_count;
}
loadRepo().catch(err => {
document.querySelector("#error").textContent = err.message;
});
And the manifest was bare-bones:
{
"manifest_version": 2,
"name": "Repo Info",
"version": "1.0",
"browser_action": {
"default_popup": "popup.html"
}
}
This sometimes worked during testing, then fell apart as soon as they added authenticated requests, custom headers, or moved part of the logic into content scripts.
The first bad assumption was this:
“Extension code is privileged, so CORS doesn’t matter.”
That’s not true in the way people mean it.
In Firefox, extension contexts like background scripts and extension pages can make cross-origin requests if the extension has the right host permissions. But content scripts are a different story. They run in the context of web pages and are much closer to normal page JavaScript from a network-policy perspective.
So the team’s architecture got messy fast:
- popup script fetched directly
- content script also tried to fetch directly from GitHub
- some requests included
Authorization - some tried to read rate limit headers
- failures were inconsistent depending on context
The actual problem
The content script was doing this on github.com pages:
async function loadFromPageContext() {
const res = await fetch("https://api.github.com/rate_limit", {
headers: {
Authorization: "Bearer " + token
}
});
const remaining = res.headers.get("X-RateLimit-Remaining");
console.log("Remaining:", remaining);
}
That’s where things broke.
Why?
- Content scripts are not the same as background scripts.
- Adding
Authorizationusually triggers a preflight request. - Cross-origin behavior in content scripts is constrained by the page context and browser rules.
- Even when the server’s CORS policy is permissive, your extension architecture can still be wrong.
The team blamed GitHub CORS. GitHub wasn’t the problem. The extension design was.
Before: direct fetches from every context
Here’s the pattern I try to stamp out early:
Popup script
fetch("https://api.github.com/repos/mdn/content")
Content script
fetch("https://api.github.com/repos/mdn/content")
Options page
fetch("https://api.github.com/user", {
headers: { Authorization: `Bearer ${token}` }
})
This spreads network logic across the extension. It makes debugging miserable. It also guarantees confusion around:
- where host permissions apply
- where CORS is enforced like a normal page
- where secrets like tokens should live
- which context should own retries and rate limiting
After: centralize requests in the background script
The fix was simple and boring, which is usually how good security architecture looks.
We moved all cross-origin API calls into the background script and let popup/content scripts send messages.
Manifest with host permissions
For Firefox, declare the domains you need:
{
"manifest_version": 2,
"name": "Repo Info",
"version": "2.0",
"permissions": [
"https://api.github.com/"
],
"browser_action": {
"default_popup": "popup.html"
},
"background": {
"scripts": ["background.js"]
}
}
If you’re using Manifest V3-style patterns in cross-browser codebases, keep the same principle: explicit host permissions, background-owned networking.
Background script
browser.runtime.onMessage.addListener((message) => {
if (message.type === "github:getRepo") {
return getRepo(message.owner, message.repo);
}
if (message.type === "github:getRateLimit") {
return getRateLimit(message.token);
}
});
async function getRepo(owner, repo) {
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
headers: {
Accept: "application/vnd.github+json"
}
});
if (!res.ok) {
throw new Error(`GitHub API failed: ${res.status}`);
}
const data = await res.json();
return {
fullName: data.full_name,
stars: data.stargazers_count,
etag: res.headers.get("ETag"),
rateLimitRemaining: res.headers.get("X-RateLimit-Remaining")
};
}
async function getRateLimit(token) {
const headers = {
Accept: "application/vnd.github+json"
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const res = await fetch("https://api.github.com/rate_limit", { headers });
if (!res.ok) {
throw new Error(`GitHub API failed: ${res.status}`);
}
return {
remaining: res.headers.get("X-RateLimit-Remaining"),
reset: res.headers.get("X-RateLimit-Reset"),
requestId: res.headers.get("X-GitHub-Request-Id"),
body: await res.json()
};
}
Popup script
async function loadRepo() {
const repo = await browser.runtime.sendMessage({
type: "github:getRepo",
owner: "mdn",
repo: "content"
});
document.querySelector("#name").textContent = repo.fullName;
document.querySelector("#stars").textContent = repo.stars;
document.querySelector("#rate").textContent = repo.rateLimitRemaining ?? "n/a";
}
loadRepo().catch(err => {
document.querySelector("#error").textContent = err.message;
});
Content script
async function fetchRateLimit() {
const result = await browser.runtime.sendMessage({
type: "github:getRateLimit"
});
console.log("Remaining requests:", result.remaining);
}
fetchRateLimit().catch(console.error);
This pattern solved three problems at once:
- one place to manage cross-origin requests
- one place to handle tokens safely
- fewer surprises about CORS behavior by execution context
Why the GitHub headers mattered
The background script could read headers like:
ETagLinkRetry-AfterX-RateLimit-RemainingX-RateLimit-ResetX-GitHub-Request-Id
because GitHub exposes them with:
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
Without access-control-expose-headers, a lot of those values would be invisible to JavaScript even if the request itself succeeded.
That detail matters for extension UX. If you want to show users:
- how many API requests they have left
- when rate limits reset
- whether a response is cacheable with
ETag
you need those headers exposed.
The mistake people make with host permissions
A lot of developers treat host permissions like a CORS bypass switch. That’s too simplistic.
Host permissions allow the extension to make requests to those origins from privileged extension contexts. They do not mean every script in your extension can ignore browser security rules. If you let content scripts call APIs directly, you are back in a murkier world.
My rule is blunt:
If the request is cross-origin and matters to your extension, do it in the background script.
That gives you a predictable trust boundary.
Security cleanup that happened after the fix
Once the team centralized requests, they also cleaned up a few non-CORS problems:
- Tokens were no longer exposed to content scripts.
- Retry logic lived in one place.
- Rate limiting behavior became consistent.
- Errors were easier to normalize before showing them in the UI.
That’s the underrated part of CORS architecture in extensions. Good CORS design often turns into good extension security design.
If you’re also tightening other headers around extension-related web assets, CSP deserves attention too. For that, I’d point people to official Mozilla docs first, and if you want a broader security-header reference, https://csp-guide.com is one of the few non-official references I’d keep around.
Practical rules for Firefox extensions
Here’s the version I actually use in projects:
- Do cross-origin fetches in background scripts, not content scripts.
- Declare precise host permissions. Don’t ask for
*://*/*unless you enjoy security reviews and user distrust. - Assume custom headers may trigger preflight.
- Check
Access-Control-Expose-Headersif you need response metadata. - Keep tokens out of content scripts whenever possible.
- Treat popup, options, background, and content scripts as different security contexts.
And if you want the official baseline behavior for Firefox extension permissions and APIs, the Mozilla documentation is where to start:
The biggest shift in mindset is this: CORS for Firefox extensions is not just about whether fetch() succeeds. It’s about which extension context owns the request. Once you make that decision correctly, most of the weirdness disappears.