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.

Published October 2, 2026 Updated October 2, 2026 8 min read
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.

A web page messaging the extensionA page on a matching origin calls chrome.runtime.sendMessage with the extension id; Chrome checks externally_connectable, delivers the message to onMessageExternal with sender details, and the worker validates and replies.app.readable.exampleChromeService workerruntime.sendMessage(id, {type:'signed-in'})origin in matches?onMessageExternal(msg, sender)validate sender…sendResponse({ok:true})
The browser enforces the origin list; your worker still validates every message.

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.

Who can reach the extensionWhether web pages, other extensions and your own content scripts can send messages under no externally_connectable key, a narrow key, and a key with ids set to all.ManifestWeb pagesOther extensionsOwn content scriptsNo keyNoAny, by idYes (internal)matches + idsListed originsListed idsYes (internal)ids: ["*"]Listed originsAnyYes (internal)
Declaring the key narrows extension access as well as opening web access.

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.

Relay content script or externally_connectable?Decision tree: a Chromium-only integration with your own site uses externally_connectable; one that must also work in Firefox uses a content script relay; one with arbitrary third-party sites uses neither and is redesigned.Which sites need to talk to the extension?your own, Chromiumexternally_connectableno content scriptValidate sendersmall message setyour own, all enginesContent script relayFirefox fallbackCheck event.sourceand originarbitrary sitesNeithertoo much exposureRedesignuser-initiated actions
Use the direct channel where it exists, and keep a relay for engines without it.

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_connectable without ids blocks 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-detect chrome.runtime on 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 matches and ids; Edge uses its own store id, so the site needs both ids or a lookup.
  • Firefox: supports externally_connectable.ids for extension-to-extension messaging but not matches for web pages. Use a content-script relay for your site.
  • Safari: supports externally_connectable with matches in 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

  1. From a page on an allowed origin, run await chrome.runtime.sendMessage("<id>", {type:"ping"}) in the console: a reply with the version.
  2. From a page on a non-listed origin, confirm chrome.runtime is undefined or the call rejects.
  3. Send a message with an unknown type and confirm no response and no side effects.
  4. 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.

Other MV3 Architecture & Extension Lifecycle Resources