Pinning a Stable Extension ID with the key Field

Give unpacked MV3 builds the same extension id as the store version using the manifest key field: how ids are derived, getting the public key, generating one before publishing, and stripping it from packages.

Published October 2, 2026 Updated October 2, 2026 7 min read
Table of Contents

Every time a teammate loads the extension unpacked from a different folder, Chrome gives it a different id — and every system that recognises the extension by id breaks. The native messaging host rejects it with “Access to the specified native messaging host is forbidden”. The OAuth redirect URI no longer matches. Your server’s allow-list of chrome-extension:// origins refuses its requests. The fix is one manifest field, key, that makes Chrome derive the id from a public key you control instead of from the folder path. This guide belongs to the manifest keys reference.

How Chrome derives an extension id

An extension id is 32 characters drawn from the letters a to p. Chrome computes it by taking the SHA-256 hash of the extension’s public key in DER form, keeping the first 128 bits, and writing each hex digit as a letter (0 → a, 1 → b, … f → p). For a store-installed extension, the public key is the one the store holds for your item, so the id is stable forever. For an unpacked extension with no key field, Chrome has no public key and hashes the absolute path of the extension’s directory instead — which is why the id changes between machines and folders. Adding the base64-encoded public key as "key" in the manifest gives the unpacked build the same input to the hash, and therefore the same id, as the published one.

Where an extension id comes fromWith a key field or a store package, Chrome hashes the public key; without one, an unpacked extension's id is derived from its directory path, which differs per machine.Public key (DER)from store or key fieldSHA-256first 128 bitshex → a–pstable idno key and unpackedDirectory path/Users/ana/src/extSHA-256of the pathDifferent idper machine
Same public key, same id — wherever the folder lives.

Step-by-step: pin the id

1. Get the public key of a published extension

If the extension is already in the Chrome Web Store, its public key is in the developer dashboard: open the item, go to the Package tab, and choose View public key. Copy the base64 text between the BEGIN PUBLIC KEY and END PUBLIC KEY lines, joined into one line without whitespace.

1{
2  "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAs7…IDAQAB"
3}

Execution context: the development manifest. Load the folder unpacked and compare the id on chrome://extensions with the store item’s id — they should match exactly. If they do not, the key was copied with line breaks or header text.

2. Or generate a key before the first publication

For an extension that is not published yet, generate a key pair yourself. Keep the private key safe — it is what you would use to sign self-hosted .crx packages — and put the public key in the manifest.

1openssl genrsa 2048 | openssl pkcs8 -topk8 -nocrypt -out extension-key.pem
2openssl rsa -in extension-key.pem -pubout -outform DER | openssl base64 -A > public-key.b64
3
4# Compute the id the key will produce
5openssl rsa -in extension-key.pem -pubout -outform DER 2>/dev/null \
6  | shasum -a 256 | head -c 32 | tr 0-9a-f a-p; echo

Execution context: a terminal. The last pipeline reproduces Chrome’s derivation so you can know the id before loading anything. When you first upload to the Chrome Web Store, you can include the key in that first package to make the store adopt your key and therefore your id; after that, the store manages the key and later uploads must omit it. Never commit extension-key.pem to a public repository.

Pinning the id before the first uploadThe developer generates a key pair, puts the public key in the manifest, loads unpacked to see the id, registers that id with OAuth and the native host, then uploads the first package including the key so the store adopts it.DeveloperChrome (unpacked)External systemsWeb Storeopenssl genrsa → key.pemload with "key"id = abcd…mnopregister id (OAuth, host)first upload incl. keystore id = same
Decide the id once, before anything external depends on it.

3. Keep the key out of store packages

1// build/manifest.mjs
2export function buildManifest(base, { target, mode }) {
3  const m = structuredClone(base);
4  if (mode === "production") delete m.key;            // store-managed from here on
5  if (target === "firefox") delete m.key;             // Firefox ignores it; keep the file clean
6  return m;
7}

Execution context: the build script. The Chrome Web Store rejects later uploads whose manifest contains a key, or strips it, depending on the circumstances; removing it in production builds avoids both. Development builds keep it so every teammate and every CI run sees the production id.

4. Use the stable id everywhere external

1// sw.js — the id is available at runtime; never hard-code a different one per environment
2const EXTENSION_ORIGIN = `chrome-extension://${chrome.runtime.id}/`;
3const REDIRECT = chrome.identity.getRedirectURL();     // https://<id>.chromiumapp.org/

Execution context: the service worker. With the key pinned, chrome.runtime.id and identity.getRedirectURL() return the same values in development and production, so one OAuth client configuration and one native host manifest serve both. See fixing OAuth redirect URI mismatches and registering native hosts on Windows, macOS and Linux.

5. Decide whether development should share the production id

Sharing the id is convenient, but it also means a development build and the store build cannot be installed side by side in one profile — Chrome treats them as the same extension. Some teams prefer a separate pinned id for development: a second key pair, registered separately with OAuth and native hosts.

1// build: choose the key by environment
2const KEYS = { dev: process.env.DEV_PUBLIC_KEY, prod: undefined };
3manifest.key = KEYS[mode];

Execution context: the build script, with the development public key in an environment variable or a committed file (public keys are not secret). A distinct development id lets testers keep the store version installed for daily use while testing the next one, at the cost of maintaining two registrations everywhere.

Id strategies comparedNo key, a key shared with production, and a separate development key compared on id stability, side-by-side installation and external registrations required.StrategyStable idSide by side with storeExternal registrationsNo keyNo, per folderYesOne per developerProduction keyYesNoOneSeparate dev keyYesYesTwo
Any pinned key beats none; the choice between shared and separate is about side-by-side testing.

Common mistakes

  • Copying the key with headers or line breaks. The field takes only the base64 body on one line. A malformed key produces a load error, or a valid but different id.
  • Uploading with the key after the first release. The store manages the key once the item exists; including it in a later package can be rejected.
  • Committing the private key. The public key is safe to commit; the PEM private key is not. Anyone with it can sign .crx files that browsers will treat as your extension in self-hosted distribution.
  • Expecting key to work in Firefox. Firefox ids come from browser_specific_settings.gecko.id, not from a key. Set that instead.
  • Changing the key later. A new key means a new id, which orphans every user’s data and every external registration. Treat the key as permanent.

Cross-browser variation

  • Chrome / Edge: key pins unpacked ids exactly as described. Edge Add-ons assigns its own id to store items, different from the Chrome Web Store’s; register both where ids matter.
  • Firefox: ignores key. Use browser_specific_settings.gecko.id for a stable id. Extension origins in Firefox are still per-install random UUIDs (moz-extension://<uuid>), so never allow-list a Firefox origin on a server.
  • Safari: ignores key. The extension’s identity comes from the containing app’s bundle identifier; origins are safari-web-extension://<uuid>.

Verification

  1. Load the folder unpacked in two different locations (or on two machines) and confirm both show the same id on chrome://extensions.
  2. Compare it with the store item’s id in the developer dashboard.
  3. Run chrome.runtime.id in the service worker console and confirm it matches.
  4. Build the production package and confirm jq .key dist/manifest.json prints null.

FAQ

Is the key secret?

No. It is a public key; anyone with your published extension can extract it. The private key that pairs with it is what must stay secret.

Can two extensions share a key?

They would get the same id, and Chrome cannot install both. Each extension needs its own key.

Does the key affect the extension’s permissions or signature?

No. In unpacked mode it only changes the id. Store-installed extensions are signed by the store regardless.

Other MV3 Architecture & Extension Lifecycle Resources