← All guides

A mock API for Chrome extension development — with zero host_permissions

Chrome extensions have an API problem that plain web apps don't: every network call is a permissions decision. Call a real third-party API that doesn't send CORS headers and you need host_permissions for its origin — which changes your install prompt, weighs on Chrome Web Store review, and couples your manifest to a backend that might not even exist yet.

While you're building the extension, there's a simpler way: point it at a hosted mock that sends Access-Control-Allow-Origin: * on every endpoint. Then MV3 service workers, popups, options pages and content scripts can all fetch it with a completely empty permissions list — no host_permissions, no permissions, nothing. Free, no signup.

Everything below was verified with a real unpacked MV3 extension loaded into Chromium 131 — the exact manifest.json, service worker and content script on this page, run against the production API before publishing.

1. Get a mock backend (30 seconds, no signup)

Try the shared demo project right now — this is the URL used in every snippet below:

curl "https://mockbird.mockbird.workers.dev/m/demo/products?limit=2"

Or create your own project with seeded data (one curl, anonymous, claim it later):

curl -X POST https://mockbird.mockbird.workers.dev/api/projects \
  -H 'content-type: application/json' -d '{"name":"my-extension","preset":"saas"}'
# → {"id":"abc123","adminKey":"...","baseUrl":"https://mockbird.mockbird.workers.dev/m/abc123", ...}

Prefer a UI? One click creates a seeded project in the dashboard. Full CRUD works — POST/PUT/PATCH/DELETE persist, so your extension can actually save things.

2. The manifest: note what's not in it

{
  "manifest_version": 3,
  "name": "Mockbird test",
  "version": "1.0",
  "background": { "service_worker": "sw.js" },
  "content_scripts": [{ "matches": ["https://example.com/*"], "js": ["content.js"] }],
  "action": { "default_popup": "popup.html" }
}

No host_permissions. No permissions. Every fetch in this guide works anyway, because the server opts in via CORS. That matters in three places:

3. Fetching from the service worker, popup, or options page

Extension pages and the MV3 service worker have a chrome-extension://<id> origin. Without host permissions their fetches follow normal CORS rules — and Mockbird approves every origin, so this just works:

// sw.js — MV3 service worker
const API = 'https://mockbird.mockbird.workers.dev/m/demo';

chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  if (msg.type === 'getProducts') {
    fetch(`${API}/products?limit=2`)
      .then(r => r.json())
      .then(data => sendResponse({ ok: true, count: data.length, first: data[0]?.name }))
      .catch(e => sendResponse({ ok: false, error: String(e) }));
    return true; // keep the channel open for the async sendResponse
  }
});

The same fetch works verbatim in popup.js or an options page script. Don't forget return true in the message listener — without it the channel closes before your async sendResponse fires, and the caller gets undefined.

4. Fetching from a content script (and why it usually fails elsewhere)

Since Chrome 85, content scripts follow the same CORS rules as the page they run in — extension permissions don't help them. A content script on https://example.com fetching a third-party API sends Origin: https://example.com, and unless that API answers with a matching Access-Control-Allow-Origin, the request is blocked. This is the source of most "my extension's fetch worked in the console but not in the content script" confusion.

Because Mockbird answers Access-Control-Allow-Origin: *, a direct content-script fetch succeeds:

// content.js — runs on https://example.com/*
const r = await fetch('https://mockbird.mockbird.workers.dev/m/demo/products?limit=1');
const data = await r.json();   // works: server sends Access-Control-Allow-Origin: *

That said, Chrome's own guidance is to make network requests from the background and relay results — it keeps fetch logic in one place and survives the day you switch to a real API that does need host permissions. The relay is small:

// content.js — ask the service worker instead of fetching directly
const res = await chrome.runtime.sendMessage({ type: 'getProducts' });
// → { ok: true, count: 2, first: "..." }   (handled by the sw.js above)

Both paths were verified in the test extension: the direct fetch and the relayed message both returned live demo data.

5. Watch your extension's requests arrive

Every Mockbird project has a request inspector (last 50 requests, with headers). It makes the origin model visible — from the verified run:

Fetch made fromOrigin header the server saw
Service worker / popupchrome-extension://mjjaocoljmjicjmkldjidbmkiodceafp
Content script on example.comhttps://example.com (the page's origin — Chrome 85 rules)

Open your project in the dashboard → "Recent requests" (the demo project's inspector is public). Handy when debugging whether a request came from your worker, your popup, or a page context — the Origin tells you instantly.

6. Test the failure modes your extension will actually hit

Extensions live in hostile network conditions: laptops sleeping, captive portals, APIs rate-limiting. Simulate all of it with query params on the same URLs:

// what does the popup show while the API is slow?
fetch(API + '/products?mock_delay=3000')

// does the badge/error state handle a 500?
fetch(API + '/products?mock_status=500')

// does your retry logic recover? (deterministic: fails once, then succeeds)
fetch(API + '/products?mock_seq=503,200&mock_seq_key=ext1')

// does it survive a flaky API? (30% of requests fail randomly)
fetch(API + '/products?mock_chaos=0.3')

Because these are just URLs, you can flip an extension into "demo a broken API" mode with a single constant — no rebuild, no separate backend deploy.

7. Extensions with accounts: mock the login flow

If your extension signs users in, the mock has a real (simulated) auth flow — any email/password returns a signed JWT, and /auth/me resolves it:

const login = await fetch(API + '/auth/login', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ email: 'dev@example.com', password: 'anything' })
}).then(r => r.json());
// → { token: "eyJ...", user: {...} }  — store it in chrome.storage, attach as Bearer

Set the project to protected mode and every endpoint starts requiring the token — so you can build and test the whole 401-handling path (token expiry included, via configurable expiresIn) before your real auth backend exists. Details in the mock JWT auth guide.

8. When you do need host_permissions

Honesty section: the zero-permissions trick works because this mock opts in with CORS. Your eventual production API may not — in that case your service worker will need host_permissions for its origin (which lets extension contexts bypass CORS), or the API team adds your extension's chrome-extension://<id> origin to its allowlist. Two things make the switch painless:

Alternatives, honestly: json-server on localhost also works for extension dev (localhost is CORS-friendly if you configure it and exempt from mixed-content blocking) — but the URL dies with your terminal, teammates can't hit it, and it can't simulate errors, latency, flakiness, or auth. MSW's service worker can't intercept requests made from an extension's own service worker. A hosted mock gives you one URL that works from every extension context, every teammate's machine, and CI.

FAQ

Does this work in Firefox / Edge extensions? The CORS logic is identical — Firefox WebExtensions and Edge (Chromium) both honor Access-Control-Allow-Origin: * from any extension context. The verified run on this page was Chromium 131 specifically.

Is there a request limit? 10,000 requests/project/day free — far beyond extension-development needs. See limits.

Can the mock push data to my extension? There's no push channel into an extension, but your extension can poll, and outbound webhooks can notify a server you control when mock data changes.

Can I import my real API's schema? Yes — POST an OpenAPI spec (or a json-server db.json, CSV, or Postman collection) and the mock matches your real resource shapes.

Try it now

curl -s -X POST https://mockbird.mockbird.workers.dev/api/projects \
  -H 'Content-Type: application/json' -d '{"preset": "saas"}'

…grab the baseUrl and drop it in your service worker. Or create a project in one click. Free, no signup. Full docs · machine-readable API index.

Related: mock JWT auth flows, testing loading and error states, standing in for a third-party API, Puppeteer, deterministic test data with snapshots.

Verification: the manifest, service worker, content script and popup on this page were loaded as a real unpacked MV3 extension into Chromium 131 on 6 Sep 2026 and run against production — the content-script direct fetch, the sendMessage relay (live demo data returned), and the popup fetch all succeeded with an empty permissions list, and the inspector rows shown in §5 are from that run. CORS preflight with a chrome-extension:// Origin was additionally verified with curl. If a snippet here doesn't work, that's a bug: tell us.