Handling API Keys Without Shipping Them in the Bundle

Every string in an extension package is public. Move third-party API keys behind your own proxy, scope what cannot move, and rotate the key you have already published.

Published September 18, 2026 Updated September 18, 2026 9 min read
Table of Contents

An extension package is a zip file any user can download and unpack. Minification is not obfuscation, environment variables are inlined at build time, and the Chrome Web Store’s own listing page links to the exact artifact. Any key in the bundle is published — the only question is how quickly someone notices. This guide is part of extension security and CSP hardening.

What “in the bundle” includes

Places a key ends up without anyone deciding to put it thereFour layers showing bundled source, build-time environment inlining, source maps and the manifest itself as places a secret becomes readable in the shipped package.Source filesa literal in a .js filethe case everyone remembersInlined env varsimport.meta.env / process.envreplaced with the literal at build timeSource mapsshipped .map filescontain the original source verbatimmanifest.jsonoauth2.client_id, key fieldsreadable without unzipping anything
Only the bottom layer is obvious — the three above it are how keys actually leak.

Step-by-step

1. Decide which category the key is in

Not every credential is a secret, and treating them all the same leads to either paranoia or leaks.

  • Public identifiers — an OAuth client_id, a Google Maps browser key restricted to your extension id. These are designed to be visible and belong in the manifest or the bundle.
  • Restricted keys — an API key that only works from an allowlisted referrer or extension id. Visible, but useless elsewhere. Acceptable to ship if the restriction is enforced server-side by the provider.
  • Genuine secrets — anything that authenticates you rather than the installation: a billing API key, a service-account credential, a signing key. These may never ship.

2. Put genuine secrets behind your own endpoint

The only place a secret can live is somewhere the user cannot read, which means a server you run. The extension authenticates to your endpoint; your endpoint holds the third-party key.

 1// extension: no third-party key anywhere
 2async function summarise(text) {
 3  const res = await fetch("https://api.example.com/v1/summarise", {
 4    method: "POST",
 5    headers: {
 6      "content-type": "application/json",
 7      authorization: `Bearer ${await getUserToken()}`,
 8    },
 9    body: JSON.stringify({ text }),
10  });
11  if (!res.ok) throw new Error(`summarise failed: ${res.status}`);
12  return res.json();
13}

Execution context: the service worker. getUserToken returns the user’s token from identity and OAuth authentication — a credential that identifies the installation and can be revoked per user, which is exactly what a shipped key cannot be.

The proxy is also where rate limiting, abuse detection and cost control belong. Those are the reasons to build it even when the upstream key could technically be restricted.

3. Where the key cannot move, restrict it hard

Some providers have no server-side option. In that case the mitigation is provider-side restriction plus a plan for abuse.

1{
2  "content_security_policy": {
3    "extension_pages": "script-src 'self'; connect-src 'self' https://api.provider.com"
4  },
5  "host_permissions": ["https://api.provider.com/*"]
6}

Execution context: parsed at install. Narrowing connect-src does not protect the key — anyone can copy it out and call the provider from anywhere — but it does stop your own extension being repurposed to send it somewhere else, and it makes an exfiltration attempt fail loudly.

In the provider’s console, restrict the key to your extension id or referrer, cap its quota, and set a budget alert. Assume the key will be used by someone else eventually and make that survivable rather than catastrophic.

4. Keep the secret out of the build

1// vite.config.js — fail rather than inline
2export default {
3  define: {
4    // Deliberately NOT exposing process.env wholesale.
5    "import.meta.env.API_BASE": JSON.stringify("https://api.example.com"),
6  },
7};

Execution context: the build, in Node. Exposing the whole environment is the mistake — a single define: { "process.env": process.env } inlines every variable on the build machine into the bundle, including CI secrets that had nothing to do with the extension.

Add a guard that fails the build if a secret-shaped string reaches the output:

1# build/check-secrets.sh
2PATTERNS='sk-[A-Za-z0-9]{20,}|AIza[0-9A-Za-z_-]{35}|-----BEGIN [A-Z ]*PRIVATE KEY'
3if grep -rEn "$PATTERNS" dist/; then
4  echo "secret-shaped string found in dist/" >&2
5  exit 1
6fi

Execution context: your shell, in CI, after the build and before packaging. It is a heuristic and it will miss a custom format — add your own provider’s prefix to the pattern list rather than trusting the defaults.

5. Do not ship source maps

1// vite.config.js
2export default { build: { sourcemap: false } };

Execution context: the build. A .map file contains the original, unminified source, including comments — everything minification was assumed to have hidden. Generate maps for your own error reporting and upload them to your error service rather than including them in the package, as described in wiring Sentry into a Manifest V3 extension.

6. Rotate what has already shipped

If a secret was published, it is compromised from the moment the version went live, and removing it in the next release does not help — the old package remains downloadable from the store’s version history and from anyone’s disk.

The sequence is: issue a new credential, deploy the server change that accepts both, ship the extension update that stops using the old one, then revoke the old credential once telemetry shows the old version’s share is negligible.

Rotating a key that already shippedA timeline from discovery through dual-accept deployment and an extension update to revocation of the old credential once old installs have drained.leak foundold key revokedIssue …same dayServer accepts bo…deploy firstExtension updatestore reviewOld versions drainauto-update rolloutRevoke…watch e…both keys validunder 1% on the old version
Revoking first breaks every user on the old version — the dual-accept window is what makes the rotation invisible.

What a proxy has to do that the direct call did not

Moving a key behind your own endpoint solves the disclosure problem and creates three new responsibilities. Skipping them turns a leaked key into an open, unmetered proxy — which is worse, because now the abuse is billed to you and looks like legitimate traffic.

Authenticate the caller. The endpoint must know which installation is calling, or it is simply a public version of the upstream API. A per-user token from your own sign-in is the usual answer; where the extension has no accounts, an installation-scoped token minted on first run is a workable middle ground.

1// service worker, first run only
2async function installationToken() {
3  const { instToken } = await chrome.storage.local.get("instToken");
4  if (instToken) return instToken;
5  const res = await fetch("https://api.example.com/v1/register", { method: "POST" });
6  const { token } = await res.json();
7  await chrome.storage.local.set({ instToken: token });
8  return token;
9}

Execution context: the service worker. This is not a security boundary against a determined user — they can register as many installations as they like — but it makes per-installation rate limiting and revocation possible, which is what the endpoint actually needs.

Meter it. Rate limit per token and per IP, and set a hard ceiling that trips an alert rather than an invoice. The number to pick is derived from what the extension can legitimately do: if a user can trigger at most one summarise per page view, a few hundred a day is generous and a few thousand is abuse.

Keep the surface narrow. The endpoint should expose the operation your extension performs, not a pass-through to the upstream API. POST /v1/summarise with a text body is bounded; POST /v1/proxy with a URL and headers is the upstream API with your key attached.

Direct call against a proxied callThe direct path ships the provider key in the bundle; the proxied path authenticates the installation to your own endpoint, which holds the key and applies rate limits.Extensionkey in the bundleProvider APIkey is all it checksAnyone with the zipsame accessreplace with an endpoint you controlExtensioninstallation tokenYour endpointauthn + rate limit + one verbProvider APIkey never leaves the server
The proxy is not just indirection — the rate limit and the narrow verb are what make it worth building.

Cross-browser variation

  • Chrome / Edge: the packaged CRX is downloadable and unpackable by anyone. The store does not scan for secrets, so nothing will warn you.
  • Firefox (AMO): reviewers read source, and a hard-coded credential is frequently flagged during review — which is better than it shipping, but it is a rejection rather than a safeguard.
  • Safari: the extension is inside an app bundle, which is no harder to open. The same rules apply.
  • All three: an extension cannot keep a secret. There is no secure storage, no attestation, and no way to prove a request came from an unmodified copy of your extension.

Verification

  1. Unpack your own published artifact and look:
1unzip -o extension.zip -d /tmp/ext && grep -rEn "sk-|AIza|BEGIN .*PRIVATE KEY|api[_-]?key" /tmp/ext | head

Execution context: your shell. Do this against the artifact you uploaded, not your source tree — the build is where inlining happens, and it is the only version that matters.

  1. Confirm no .map files are present in the package.
  2. In DevTools Network on the service worker, confirm every outbound request goes to your own origin or to a provider you intended, and that no request carries a static key in a header or query string.
  3. Check connect-src blocks a request to an unexpected host by adding one temporarily.

FAQ

Is an obfuscated key any better?

No, and obfuscation is itself a review risk — the Chrome Web Store rejects code it cannot read. A key that is decoded at runtime is a key with an extra step.

Can I store the key in chrome.storage instead?

That only moves the problem: the extension has to obtain it from somewhere, and whatever it does to fetch it, a user can do too. Storage is not a vault.

What about a key the user supplies themselves?

That is fine and is a good pattern for power-user tools. Store it in chrome.storage.local, never in sync — and say clearly in the UI that it is held on the device, as in encrypting sensitive data in chrome.storage.

Other MV3 Architecture & Extension Lifecycle Resources