Cross-Extension and Native Messaging

Talk to another extension or a native host from MV3: externally_connectable, sendMessage with an extension id, native messaging host manifests, and validating every inbound sender.

Published July 25, 2026 Updated July 25, 2026 9 min read
Table of Contents

Two channels reach outside your own extension, and both hand a caller something no web page can have. chrome.runtime.sendMessage with an extension id talks to another extension; chrome.runtime.connectNative talks to a program on the user’s machine. Each needs an explicit declaration on both sides, and each turns your message handler into a public interface that has to authenticate what arrives. This page is part of Message Passing Architecture and covers both channels plus the validation they require.

Root cause: these channels are opt-in in both directions

Nothing can message your extension from outside unless you declared that it may. externally_connectable lists the extension ids and URL patterns allowed to reach you, and a caller not on that list is refused by the browser before your listener runs. Native messaging is symmetrical: your extension declares the host name it may connect to, and the host’s own manifest — a JSON file installed on the machine — lists the extension origins allowed to connect to it.

The four sources a message can come fromOwn contexts, other extensions, allow-listed web pages and a native host, ordered by how much declaration each requires.Your own contextspopup, options, content scriptsno declaration neededAnother extensionexternally_connectable.idsboth sides declareAn allow-listed web pageexternally_connectable.matchestreat as fully untrustedA native hostnativeMessaging permissionhost manifest on the machine
Everything below the first layer requires a manifest declaration on both ends — and validation in your handler regardless.

Step 1. Declare the channels you actually want

Both keys are narrow by default and should stay that way. An externally_connectable.matches entry of *://*/* turns your privileged handler into an open endpoint for the entire web.

 1{
 2  "manifest_version": 3,
 3  "name": "Field Notes Bridge",
 4  "version": "2.0.0",
 5
 6  "permissions": ["nativeMessaging", "storage"],
 7
 8  "externally_connectable": {
 9    // Specific extension ids only — a companion extension you also publish.
10    "ids": ["abcdefghijklmnopabcdefghijklmnop"],
11    // Specific origins only. Never a wildcard host.
12    "matches": ["https://app.example.com/*"],
13    "accepts_tls_channel_id": false
14  },
15
16  "background": { "service_worker": "background.js", "type": "module" }
17}

Execution context: Parsed at install time. Omitting externally_connectable entirely is the correct default — with the key absent, no page and no other extension can reach you, and chrome.runtime.onMessageExternal never fires. The nativeMessaging permission is separate and does not itself name a host; the host name is supplied at connect time and must match a host manifest installed on the machine. Firefox supports both keys; Safari supports neither native messaging in the Chrome sense nor externally_connectable, so treat both as Chromium and Firefox features.

Step 2. Handle external messages in their own listener

External messages arrive on onMessageExternal, not onMessage. Keeping them in a separate listener with a separate command table is what stops an internal-only operation from being reachable from outside.

 1// background.js
 2const PARTNER_IDS = new Set(["abcdefghijklmnopabcdefghijklmnop"]);
 3const PARTNER_ORIGINS = new Set(["https://app.example.com"]);
 4
 5// Deliberately small: only what an outside caller is allowed to do.
 6const EXTERNAL_COMMANDS = {
 7  async getPublicStatus() {
 8    const { lastSyncAt } = await chrome.storage.local.get("lastSyncAt");
 9    return { ok: true, lastSyncAt: lastSyncAt ?? null };
10  },
11};
12
13chrome.runtime.onMessageExternal.addListener((message, sender, sendResponse) => {
14  const allowed =
15    (sender.id && PARTNER_IDS.has(sender.id)) ||
16    (sender.origin && PARTNER_ORIGINS.has(sender.origin));
17
18  if (!allowed) {
19    sendResponse({ ok: false, error: "forbidden" });
20    return false;
21  }
22
23  const command = EXTERNAL_COMMANDS[message?.type];
24  if (!command) {
25    sendResponse({ ok: false, error: "unknown-command" });
26    return false;
27  }
28
29  (async () => sendResponse(await command(message, sender)))();
30  return true;
31});

Execution context: Extension service worker, registered at the top level. The browser has already enforced the manifest allow list by the time this runs, so the checks here are defence in depth — but they matter, because externally_connectable is one manifest edit away from being wider than you remember. The separate EXTERNAL_COMMANDS table is the structural part: an internal command added later is not automatically exposed, which is exactly the failure mode a single shared switch statement has. This is the same reasoning applied more broadly in extension security and CSP hardening.

An inbound external message, gate by gateThe browser checks the manifest allow list, then the listener checks the sender, then the command table decides what is reachable.Partner pageBrowserService workerCommand tableruntime.sendMessage(extId, msg)is the origin in externally_connectable?onMessageExternal(message, sender)is sender.id or sender.origin allowed?look the command upexternal table onlynarrow, public response
Three independent gates, because the first two are configuration and only the third is code you control at review time.

Step 3. Connect to a native host

Native messaging exchanges newline-free JSON over stdio with a program the user installed. The extension side is small; the operational work is the host manifest, which has to be placed in a per-platform location and must name your extension id explicitly.

 1// background.js — a long-lived native port, reconnected on demand
 2const HOST = "com.example.fieldnotes";
 3
 4function connectNative() {
 5  const port = chrome.runtime.connectNative(HOST);
 6
 7  port.onMessage.addListener((message) => {
 8    void handleNativeMessage(message);
 9  });
10
11  port.onDisconnect.addListener(() => {
12    // Host missing, host crashed, or the worker is being evicted. All look the same here.
13    const error = chrome.runtime.lastError?.message;
14    console.warn("native host disconnected", error ?? "(no error)");
15  });
16
17  return port;
18}

Execution context: Extension service worker. connectNative returns immediately and failures surface through onDisconnect with chrome.runtime.lastError — a missing host manifest is indistinguishable from a crashed host at this level, so log the error text rather than assuming. A native port keeps the worker alive while it is open, which makes it a keepalive by side effect; that is a reason to close it when idle rather than a reason to hold it open, as covered in keeping service workers alive during long tasks.

 1// com.example.fieldnotes.json — installed on the user's machine, not in the extension
 2{
 3  "name": "com.example.fieldnotes",
 4  "description": "Field Notes local bridge",
 5  "path": "/usr/local/bin/fieldnotes-bridge",   // absolute path; must be executable
 6  "type": "stdio",
 7  "allowed_origins": [
 8    "chrome-extension://abcdefghijklmnopabcdefghijklmnop/"   // trailing slash required
 9  ]
10}

Execution context: Read by the browser at connect time from a platform-specific directory — under ~/.config/google-chrome/NativeMessagingHosts/ on Linux, ~/Library/Application Support/Google/Chrome/NativeMessagingHosts/ on macOS, and a registry key on Windows. Firefox uses allowed_extensions with the extension id instead of allowed_origins, so a cross-browser installer writes two manifests. The trailing slash in the origin is mandatory and its absence is a common cause of a connection that closes immediately with no useful error.

Step 4. Treat both channels as untrusted input

Neither channel authenticates a user, only a caller. A partner extension can be compromised; a native host can be replaced by anything the user is tricked into installing. So the same discipline applies to both: a narrow command surface, validated arguments, and no operation that would be dangerous if the caller were hostile.

1// background.js — validate shape, not just membership
2function readSyncRequest(message) {
3  const since = Number(message?.since);
4  const limit = Number(message?.limit);
5  return {
6    since: Number.isFinite(since) && since >= 0 ? since : 0,
7    limit: Number.isFinite(limit) ? Math.min(Math.max(limit, 1), 500) : 50,
8  };
9}

Execution context: Extension service worker, applied to messages from both external channels. Coercing and clamping every field means a hostile caller cannot turn a sync request into an unbounded read, and it removes the need to trust the caller’s own validation. Native messages have one extra constraint worth designing around: each message is size-limited (about 1 MB from the host, 4 GB to it in Chrome), so a host that wants to send a large result should chunk it rather than assume one message will carry it.

The two outward channels comparedCross-extension messaging and native messaging compared on declaration, transport, size limits and browser support.AspectCross-extensionNative hostDeclared byexternally_connectablenativeMessaging + host manifestTransportBrowser IPCstdio JSONInbound eventonMessageExternalport.onMessageMessage size limitPractical only~1 MB from hostKeeps the worker aliveNoWhile connectedSafari supportNoneApp container only
Native messaging is the only one of the two that depends on something installed outside the browser — which is why its failure modes are operational rather than code-level.

Cross-browser variation

  • Chrome 120+: both channels supported. externally_connectable accepts ids and matches; native host manifests use allowed_origins with a trailing slash.
  • Firefox 121+: both supported. Native host manifests use allowed_extensions with the extension id, and live in a different directory per platform — plan for two manifest files in your installer.
  • Safari 17+: no externally_connectable and no stdio native messaging. The equivalent is an app extension bundled with a native container app, which communicates through the app rather than through this API.
  • All engines: sender.origin is populated for page senders and sender.id for extension senders. Absence of both means the message did not come from where you think — reject it.

Verification

  1. Send a message from an extension id that is not in externally_connectable.ids. It must be refused by the browser, with your listener never running.
  2. Call an internal-only command name through onMessageExternal. Expect unknown-command, proving the tables are separate.
  3. Remove the native host manifest and connect. onDisconnect must fire with an error, and the extension must degrade rather than hang.
  4. Send a native message with an out-of-range limit and confirm the clamped value is what reaches your query.

FAQ

Do I need externally_connectable to talk to my own other extension?

Yes, on the receiving side. The sender only needs the target’s extension id, but the receiver must list the sender’s id in externally_connectable.ids, or the browser refuses the connection before any listener runs.

Can a web page message my extension without externally_connectable?

Not directly. Without the key there is no channel at all. A page can still reach you indirectly through a content script you injected, which is a different trust path — and one where sender.tab tells you which page it was.

Why does connectNative disconnect immediately?

Almost always the host manifest: a wrong path, a non-executable binary, a missing trailing slash in the origin, or the wrong directory for the platform. Read chrome.runtime.lastError in onDisconnect — it usually names which.

Does an open native port keep the service worker alive?

Yes, while it is connected. That is a side effect rather than a feature: holding a port open to avoid cold starts keeps a native process running too, so open it for the exchange and close it afterwards.

Other Core APIs & Cross-Browser Data Management Resources