CORS in Microsoft Edge extensions trips people up because extensions are not normal web pages, but they’re also not completely exempt from browser security rules. I’ve seen teams waste hours debugging a “CORS issue” that was actually a host permission problem, a content script limitation, or a server sending the wrong headers.
If you build Edge extensions, you need to separate three execution contexts in your head:
- Content scripts
- Extension pages like popup, options, side panel
- Background/service worker
That distinction explains most CORS bugs.
Mistake #1: Assuming extensions bypass CORS everywhere
A common belief is: “It’s an extension, so CORS doesn’t apply.” That’s wrong enough to cause damage.
In Edge extensions, privileged extension contexts can make cross-origin requests if you declare the right permissions. But content scripts run in the context of the page, which means they often inherit the page’s restrictions.
Broken mental model
// content-script.js
fetch("https://api.github.com/user")
.then(r => r.json())
.then(console.log)
.catch(console.error);
You might expect this to work because your extension has permissions. But from a content script, this can still behave like a page request and hit CORS restrictions.
Fix
Move cross-origin requests into the background service worker or another extension page, then message the result back.
{
"manifest_version": 3,
"name": "Edge CORS Example",
"version": "1.0.0",
"permissions": ["storage"],
"host_permissions": ["https://api.github.com/*"],
"background": {
"service_worker": "background.js"
},
"content_scripts": [
{
"matches": ["https://example.com/*"],
"js": ["content-script.js"]
}
]
}
// content-script.js
chrome.runtime.sendMessage({ type: "GET_GITHUB_USER" }, (response) => {
if (response?.error) {
console.error(response.error);
return;
}
console.log(response.data);
});
// background.js
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === "GET_GITHUB_USER") {
fetch("https://api.github.com/user", {
headers: {
"Accept": "application/vnd.github+json"
}
})
.then(async (res) => {
const data = await res.json();
sendResponse({ data, status: res.status });
})
.catch((err) => {
sendResponse({ error: err.message });
});
return true;
}
});
That pattern avoids a lot of pain.
Mistake #2: Forgetting host_permissions
This one is boring, but it’s probably the most common real bug.
You write a perfectly valid fetch(), the server supports CORS, and Edge still blocks the request. Why? Because your extension doesn’t have permission to access that origin.
Broken manifest
{
"manifest_version": 3,
"name": "Broken Extension",
"version": "1.0.0",
"background": {
"service_worker": "background.js"
}
}
Fix
Declare the exact hosts you need.
{
"manifest_version": 3,
"name": "Working Extension",
"version": "1.0.0",
"host_permissions": [
"https://api.github.com/*",
"https://example.internal/*"
],
"background": {
"service_worker": "background.js"
}
}
Be specific. Don’t slap https://*/* in there unless you really need it. Broad host permissions are a security smell and a review headache.
Mistake #3: Debugging the wrong error
Developers often call every failed cross-origin request “a CORS error.” Edge DevTools then shows a vague network failure, and everyone starts staring at Access-Control-Allow-Origin.
Sometimes that header is fine.
For example, api.github.com sends:
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
That’s a valid CORS setup for many public API use cases. If your request still fails, check:
- Missing
host_permissions - Request made from a content script instead of background
- Authentication headers triggering preflight
- Server rejecting
OPTIONS - Credentials used with wildcard origin
- CSP blocking something else
Not every red error in DevTools is caused by the same layer.
Mistake #4: Using credentials with Access-Control-Allow-Origin: *
This one burns people when they move from public endpoints to authenticated APIs.
If your request includes cookies or other credentials, the server cannot use:
Access-Control-Allow-Origin: *
That wildcard works for anonymous requests, not credentialed ones.
Broken request
fetch("https://api.example.com/me", {
credentials: "include"
});
Broken response
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
That combination is invalid for credentialed CORS.
Fix
The server must return the specific origin, not *.
Access-Control-Allow-Origin: chrome-extension://<your-extension-id>
Access-Control-Allow-Credentials: true
Vary: Origin
For Edge, extension origins use the extension scheme and ID. If your backend supports browser extensions directly, make sure it explicitly allows your extension origin.
If you control the API, this is straightforward. If you don’t, stop trying to force cookies into a public CORS policy and switch to token-based auth in the background worker.
Mistake #5: Triggering preflight accidentally
A simple GET can become a preflighted request the moment you add a non-simple header like Authorization.
fetch("https://api.example.com/data", {
headers: {
"Authorization": "Bearer token123"
}
});
Now the browser may send an OPTIONS request first. If the server doesn’t answer properly, your actual request never goes out.
What the server needs
Access-Control-Allow-Origin: chrome-extension://<your-extension-id>
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type
Fixes
- Don’t add custom headers unless you need them
- Make sure the API handles
OPTIONS - Match the exact headers you send
- Keep requests “simple” when possible
I’ve seen APIs allow GET, POST and forget OPTIONS, then everyone blames Edge. That’s not Edge. That’s a half-configured server.
Mistake #6: Reading headers that CORS doesn’t expose
Even when the request succeeds, JavaScript cannot read every response header by default. That surprises developers using rate limit headers, pagination, or ETags.
GitHub gets this right by exposing headers like:
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
That means frontend code can read them.
Example
const res = await fetch("https://api.github.com/repos/microsoft/edge");
console.log(res.headers.get("ETag"));
console.log(res.headers.get("X-RateLimit-Remaining"));
If your API does not expose those headers, res.headers.get() will return null even though the browser received them.
Fix
Expose what your extension actually needs.
Access-Control-Expose-Headers: ETag, Link, X-RateLimit-Remaining
This is one of those bugs that looks like JavaScript is broken when it’s really just CORS doing exactly what it’s supposed to do.
Mistake #7: Trying to “fix CORS” by weakening security everywhere
I still see developers do this:
Access-Control-Allow-Origin: *
Access-Control-Allow-Headers: *
Access-Control-Allow-Methods: *
That’s not a serious fix. It’s panic-driven config.
For extension traffic, be explicit. If your API is only for your Edge extension, allow only that extension origin. If it’s public, allow public access carefully and avoid credentials. Don’t turn your CORS policy into a giant wildcard because one fetch failed on Friday afternoon.
Also, if you start changing security headers beyond CORS, know what you’re touching. If you need to review CSP interactions, CSP Guide is useful, and for Edge extension specifics, stick to Microsoft’s official extension docs.
Mistake #8: Ignoring the service worker lifecycle in MV3
Manifest V3 background logic runs in a service worker, not a persistent background page. That changes how you structure fetch flows.
A common bug is assuming in-memory state will still exist after the worker goes idle.
Bad pattern
let token = null;
chrome.runtime.onInstalled.addListener(() => {
token = "abc123";
});
Later, your fetch fails because the service worker restarted and token is gone. Then somebody blames CORS because the request returned 401 or never got the right header.
Fix
Persist state properly.
async function getToken() {
const result = await chrome.storage.local.get("token");
return result.token;
}
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === "CALL_API") {
(async () => {
const token = await getToken();
const res = await fetch("https://api.example.com/data", {
headers: {
Authorization: `Bearer ${token}`
}
});
sendResponse({ status: res.status });
})().catch(err => sendResponse({ error: err.message }));
return true;
}
});
If your auth state disappears, you’ll diagnose the wrong thing unless you remember MV3’s lifecycle.
Mistake #9: Not checking official Edge extension behavior
Edge extension APIs are largely Chromium-based, but “largely” isn’t the same as “identical in every edge case.” When permissions, host access, or network behavior feels off, verify against Microsoft’s official documentation instead of relying on random forum folklore.
Use the official docs for:
- Manifest permissions
- Host permissions
- Content scripts vs extension pages
- MV3 service worker behavior
That saves time and keeps your fixes grounded in actual platform behavior.
A practical rule of thumb
When an Edge extension request fails, I check things in this order:
- Where did the request originate? Content script or background?
- Do I have
host_permissionsfor that exact origin? - Is the request simple, or did I trigger preflight?
- Does the server answer
OPTIONScorrectly? - Am I mixing credentials with wildcard origin?
- Do I need exposed response headers?
- Is MV3 service worker state causing a fake “CORS bug”?
That checklist catches most real-world failures fast.
CORS for Edge extensions isn’t magic. It’s just a messy overlap between browser security, extension privileges, and server policy. Once you separate those pieces, the bugs get a lot less mysterious.