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.
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.
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.
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.
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
.crxfiles that browsers will treat as your extension in self-hosted distribution. - Expecting
keyto work in Firefox. Firefox ids come frombrowser_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:
keypins 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. Usebrowser_specific_settings.gecko.idfor 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 aresafari-web-extension://<uuid>.
Verification
- Load the folder unpacked in two different locations (or on two machines) and confirm both show the same id on
chrome://extensions. - Compare it with the store item’s id in the developer dashboard.
- Run
chrome.runtime.idin the service worker console and confirm it matches. - Build the production package and confirm
jq .key dist/manifest.jsonprintsnull.
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.
Related
- Externally connectable matches and ids — another feature that depends on a known id.
- Browser-specific settings for Firefox and Safari — Firefox’s equivalent of a stable id.
- Generating a manifest per browser target — adding and stripping the key per build.
- Manifest keys reference — the parent topic.