externally_connectable Matches and IDs
Let your website or companion extensions message an MV3 extension with externally_connectable: matches and ids, onMessageExternal, sender validation, accepts_tls_channel_id and Firefox's alternative.
Table of Contents
Your web app wants to tell the extension that the user just signed in, or ask whether it is installed, or hand it a document to process. The obvious approach — a content script on your site relaying window.postMessage — works but adds a script to every page load and trusts a channel any script on the page can write to. Chrome offers a direct path: list your site in externally_connectable, and its pages can call chrome.runtime.sendMessage(extensionId, message) straight into your service worker. Configured narrowly, it is simpler and safer than a relay; configured broadly, it hands every listed origin a remote control for your extension. This guide belongs to the manifest keys reference.
How external messaging works
Without the key, any other extension can message yours by id, and no web page can. With the key, the rules invert and narrow: only the extension ids listed in ids (or all, with "*") and only web pages whose URLs match matches may connect. Matching pages get a chrome.runtime object with sendMessage and connect, taking your extension id as the first argument. Messages arrive in the service worker on chrome.runtime.onMessageExternal and onConnectExternal — separate events from the internal ones, so external traffic can never be mistaken for a message from your own content scripts. Each message carries a sender with the page’s url and origin (or the sending extension’s id), which is what your validation relies on.
Step-by-step: a narrow, validated channel
1. Declare the exact origins and ids
1{
2 "externally_connectable": {
3 "matches": [
4 "https://app.readable.example/*",
5 "https://readable.example/install/*"
6 ],
7 "ids": ["bcdefghijklmnopabcdefghijklmnopa"] // companion extension, if any
8 }
9}
Execution context: the manifest. Patterns must name a specific second-level domain — Chrome rejects <all_urls>, *://*/* and patterns like https://*.com/* here. Including externally_connectable at all removes the default “any extension may message me” behaviour; list ids explicitly, or "*" if you deliberately accept all extensions. Path parts in matches are honoured, so /install/* restricts which pages on that origin may connect.
2. Handle external messages on their own event
1// sw.js — top level
2const ALLOWED_ORIGINS = new Set(["https://app.readable.example", "https://readable.example"]);
3
4chrome.runtime.onMessageExternal.addListener((msg, sender, sendResponse) => {
5 if (!ALLOWED_ORIGINS.has(sender.origin)) return; // defence in depth
6 switch (msg?.type) {
7 case "ping":
8 sendResponse({ installed: true, version: chrome.runtime.getManifest().version });
9 return;
10 case "signed-in":
11 if (typeof msg.token !== "string" || msg.token.length > 4096) return;
12 storeSessionToken(msg.token).then(() => sendResponse({ ok: true }));
13 return true; // async reply
14 }
15});
Execution context: the service worker. The manifest already restricts who can send, but checking sender.origin again in code protects against a future manifest edit that broadens the list. Accept a small, explicit set of message types with validated fields; never expose operations like “fetch this URL” or “run this in a tab” to external callers. A compromised or XSS-vulnerable page on an allowed origin can send anything the page can.
3. Call the extension from your site
1// app.readable.example — page script
2const EXTENSION_ID = "abcdefghijklmnopabcdefghijklmnop";
3
4export async function extensionInstalled() {
5 if (!globalThis.chrome?.runtime?.sendMessage) return false; // not Chromium, or no match
6 try {
7 const reply = await chrome.runtime.sendMessage(EXTENSION_ID, { type: "ping" });
8 return !!reply?.installed;
9 } catch {
10 return false; // not installed or disabled
11 }
12}
Execution context: a normal web page on a matching origin. chrome.runtime exists on the page only when at least one installed extension lists the page in externally_connectable; its absence means “not Chromium or not installed”. When the extension is not installed, sendMessage rejects with “Could not establish connection”. The extension id must be the store id — pin development builds to the same id with the key field, covered in pinning a stable extension id with the key field.
4. Use ports for ongoing conversations
1// page
2const port = chrome.runtime.connect(EXTENSION_ID, { name: "sync-status" });
3port.onMessage.addListener((m) => renderStatus(m));
4
5// sw.js
6chrome.runtime.onConnectExternal.addListener((port) => {
7 if (port.name !== "sync-status" || !ALLOWED_ORIGINS.has(port.sender.origin)) return port.disconnect();
8 const send = () => port.postMessage(currentSyncStatus());
9 send();
10 const unsubscribe = onSyncStatusChange(send);
11 port.onDisconnect.addListener(unsubscribe);
12});
Execution context: the page and the service worker. An open port from a web page keeps the service worker alive, like any port, so close it when the page no longer needs updates. Ports are the right tool for a web dashboard that shows live extension state.
5. Provide a relay where the key is unsupported
1// content script on https://app.readable.example/* — Firefox and Safari fallback
2window.addEventListener("message", async (e) => {
3 if (e.source !== window || e.origin !== location.origin) return;
4 if (e.data?.channel !== "readable-ext") return;
5 const reply = await chrome.runtime.sendMessage({ external: true, payload: e.data.payload });
6 window.postMessage({ channel: "readable-ext-reply", id: e.data.id, reply }, location.origin);
7});
Execution context: a content script in the isolated world of your own site. The worker should treat relayed messages exactly like external ones — same validation, same small message set — because any script on the page can post them. See protecting extension messages from web pages.
6. Version the external protocol
Your website and your extension deploy independently: the site can change within minutes, while extension updates reach users over hours or days. Include a protocol version in external messages and answer ping with the versions the extension understands, so the site can avoid features the installed extension does not support yet.
1case "ping":
2 sendResponse({ installed: true, version: chrome.runtime.getManifest().version, protocols: [1, 2] });
3 return;
Execution context: the service worker. The page checks protocols.includes(2) before sending version-2 messages and falls back otherwise. This is the same discipline as versioning message schemas across updates, applied to a channel whose two ends you release on different schedules.
Common mistakes
- Trusting the page because it is your domain. An XSS bug on an allowed origin turns into control of the extension. Validate message shapes and expose only safe operations.
- Adding the key and breaking companion extensions. Declaring
externally_connectablewithoutidsblocks other extensions that used to message you. List them. - Using a development id on the website. If the site hard-codes the store id, unpacked builds without a pinned key never respond.
- Expecting the key in Firefox. Firefox does not support web-page
externally_connectable; feature-detectchrome.runtimeon the page and fall back to a relay. - Leaving ports open. A dashboard tab that never closes its port keeps the worker alive indefinitely.
Cross-browser variation
- Chrome / Edge: full support for
matchesandids; Edge uses its own store id, so the site needs both ids or a lookup. - Firefox: supports
externally_connectable.idsfor extension-to-extension messaging but notmatchesfor web pages. Use a content-script relay for your site. - Safari: supports
externally_connectablewithmatchesin recent versions; the extension id on the page is the Safari extension’s identifier. Test it explicitly and keep the relay as a fallback.
Verification
- From a page on an allowed origin, run
await chrome.runtime.sendMessage("<id>", {type:"ping"})in the console: a reply with the version. - From a page on a non-listed origin, confirm
chrome.runtimeis undefined or the call rejects. - Send a message with an unknown
typeand confirm no response and no side effects. - From a companion extension, confirm messages still arrive if its id is listed and are rejected if not.
FAQ
Can I use wildcards for subdomains?
Yes — https://*.readable.example/* is allowed because it names a specific second-level domain. Wildcards at the domain level, like https://*.example/* on a public suffix, are rejected.
What is accepts_tls_channel_id?
A legacy option that passed a TLS channel id to the extension. TLS channel ids are no longer supported in Chrome; omit it.
Does the page need any permission?
No. Being listed in matches is the permission.
Related
- Cross-extension and native messaging — messaging other extensions by id.
- Protecting extension messages from web pages — the relay’s threat model.
- Declaring web accessible resources correctly — the other key that exposes the extension to the web.
- Manifest keys reference — the parent topic.