Native Messaging & Host Integration

Connect an MV3 extension to a native app with connectNative and sendNativeMessage: host manifests, the length-prefixed wire format, per-OS registration, Safari's app extension model and debugging.

Some jobs cannot be done inside the browser sandbox at all: talking to a hardware token, reading a file outside the downloads folder, driving a desktop password manager, or handing work to a local language model. Native messaging is the sanctioned bridge. The extension starts a native executable — the host — and exchanges JSON messages with it over standard input and output. The browser launches the process, checks that your extension is allowed to talk to it, and kills it when the connection closes. This topic sits inside Core APIs & Cross-Browser Data Management, and its foundation is the framing rule described in the native messaging wire format.

The extension side is three API calls. Nearly all the difficulty is outside the browser: a host manifest file in an OS-specific location, a registry key on Windows, an executable path that must be absolute, a four-byte length prefix that must be written in native byte order, and a host process that must never print anything to standard output except framed messages. Get any of those wrong and the browser reports one of four terse errors, the most common being “Native host has exited”, which tells you only that something went wrong after launch.

The pieces of a native messaging connectionThe extension calls connectNative with a host name; the browser looks up the host manifest in an OS-specific location, checks allowed_origins, launches the executable and pipes length-prefixed JSON over stdin and stdout.ExtensionconnectNative("com.acme.host")Browserfind host manifestHost manifestpath + allowed_originslaunch the executable with the caller's originNative hostyour executablestdin / stdout4-byte length + JSONPort eventsonMessage, onDisconnect
The browser owns the process lifetime; your extension owns the protocol.

Prerequisites checklist

  • The nativeMessaging permission in the extension manifest.
  • A stable extension id in every environment, so the host manifest’s allowed_origins can name it — see pinning a stable extension id with the key field.
  • A host executable that reads and writes length-prefixed UTF-8 JSON on stdin and stdout, and logs only to stderr.
  • A host manifest JSON file registered in the location each browser and OS expects.
  • An installer, or at least a script, that places both the executable and the manifest — users cannot be expected to edit the registry.
  • A plan for Safari, where the host is the containing macOS app rather than a separate executable.

Manifest registration

Two manifests are involved: the extension’s, which asks for the permission, and the host’s, which tells the browser how to launch the host and who may call it.

1// extension manifest.json
2{
3  "manifest_version": 3,
4  "name": "Acme Vault Connector",
5  "version": "2.0.0",
6  "key": "MIIBIjANBgkqh…",              // pins the id used in allowed_origins
7  "background": { "service_worker": "sw.js", "type": "module" },
8  "permissions": ["nativeMessaging"]      // install warning: "Communicate with cooperating native applications"
9}
1// host manifest: com.acme.vault.json
2{
3  "name": "com.acme.vault",               // lower-case, dots and underscores only
4  "description": "Acme Vault native bridge",
5  "path": "/Applications/Acme Vault.app/Contents/MacOS/vault-host",  // absolute on macOS/Linux
6  "type": "stdio",
7  "allowed_origins": ["chrome-extension://abcdefghijklmnopabcdefghijklmnop/"]  // trailing slash required
8}

Execution context: the extension manifest is read at install; the host manifest is read by the browser every time connectNative or sendNativeMessage is called, so edits take effect without reinstalling the extension. Firefox uses allowed_extensions with add-on ids instead of allowed_origins. Safari ignores both: messages go to the containing app’s extension handler.

1. One-shot requests with sendNativeMessage

For a request that needs one answer — “is the vault unlocked?”, “what version is installed?” — sendNativeMessage starts the host, sends one message, waits for one reply, and lets the browser terminate the process.

 1// sw.js
 2export async function hostVersion() {
 3  try {
 4    const reply = await chrome.runtime.sendNativeMessage("com.acme.vault", { type: "version" });
 5    return reply.version;
 6  } catch (err) {
 7    // "Specified native messaging host not found." etc.
 8    return { error: err.message };
 9  }
10}

Execution context: the service worker, or any extension page with the permission — never a content script. Each call launches a fresh process, which costs tens to hundreds of milliseconds depending on the host’s runtime. Firefox resolves with the same reply shape. Safari accepts the call with any application id string and routes it to the containing app.

sendNativeMessage versus connectNativeComparison of the one-shot and port-based native messaging APIs on process lifetime, latency, worker lifetime and suitable use.AspectsendNativeMessageconnectNativeProcess lifetimeOne requestUntil disconnectStartup costEvery callOnceKeeps worker aliveNoYes, Chrome 105+Host can pushNoYesSafariSupportedLimited
Use one-shot for occasional queries; use a port when the host holds state or streams.

2. Long-lived connections with connectNative

When the host holds state — an unlocked session, an open device handle — or pushes events back, open a port and keep it.

 1let port = null;
 2
 3export function hostPort() {
 4  if (port) return port;
 5  port = chrome.runtime.connectNative("com.acme.vault");
 6  port.onMessage.addListener(onHostMessage);
 7  port.onDisconnect.addListener(() => {
 8    console.warn("[host] disconnected:", chrome.runtime.lastError?.message);
 9    port = null;
10  });
11  return port;
12}
13
14hostPort().postMessage({ type: "subscribe", topics: ["lock-state"] });

Execution context: the service worker. In Chrome 105 and later an open native port keeps the service worker alive, which is both useful and a responsibility: a port you never close keeps the worker resident indefinitely. chrome.runtime.lastError inside onDisconnect carries the reason. Firefox exposes it as port.error.

3. The host side

The host is any executable that reads a 32-bit length in native byte order, then that many bytes of UTF-8 JSON, and writes replies in the same framing. Chrome passes the caller’s origin as the first argument; Firefox passes the manifest path and the add-on id.

 1#!/usr/bin/env node
 2// vault-host.js — minimal Node host
 3process.stdin.on("readable", () => {
 4  let header;
 5  while ((header = process.stdin.read(4)) !== null) {
 6    const len = header.readUInt32LE(0);
 7    const body = process.stdin.read(len);
 8    if (body === null) { process.stdin.unshift(header); break; }
 9    handle(JSON.parse(body.toString("utf8")));
10  }
11});
12
13function send(msg) {
14  const json = Buffer.from(JSON.stringify(msg), "utf8");
15  const header = Buffer.alloc(4);
16  header.writeUInt32LE(json.length, 0);
17  process.stdout.write(Buffer.concat([header, json]));
18}
19
20function handle(msg) {
21  if (msg.type === "version") send({ version: "2.0.0" });
22}

Execution context: a separate OS process launched by the browser, with the user’s privileges and no browser APIs. readUInt32LE is correct on every platform browsers ship on today, all of which are little-endian. A single console.log in this file corrupts the stream — the browser reads the text as a length prefix and disconnects. A complete, buffered implementation is in writing a native messaging host in Node.

Anatomy of one framed messageA native message is a four-byte little-endian length followed by that many bytes of UTF-8 JSON; nothing else may appear on stdout.Length prefixuint32, native byte order4 bytesJSON payloadUTF-8 encodedexactly length bytesNext length prefiximmediately followsno separatorstderrlogs go herenever mixed into stdout
The length counts bytes, not characters — an emoji is four of them.

4. Registering the host

The browser finds the host manifest by name, in a location that depends on the OS and the browser. On Windows the location is a registry key whose default value is the manifest’s path; on macOS and Linux it is a fixed directory. Chrome, Edge, Chromium, Brave and Firefox each read different directories, so an installer that supports several browsers writes the same manifest to several places.

1# macOS, per-user, Chrome
2mkdir -p "$HOME/Library/Application Support/Google/Chrome/NativeMessagingHosts"
3cp com.acme.vault.json "$HOME/Library/Application Support/Google/Chrome/NativeMessagingHosts/"
4
5# Linux, per-user, Firefox (uses allowed_extensions in the manifest)
6mkdir -p "$HOME/.mozilla/native-messaging-hosts"
7cp com.acme.vault.firefox.json "$HOME/.mozilla/native-messaging-hosts/com.acme.vault.json"

Execution context: an installer or setup script running as the user, outside any browser. Per-user locations need no elevation; system-wide locations need admin rights and are what enterprise deployments use. The full table of paths and registry keys is in registering native hosts on Windows, macOS and Linux.

5. Handling failure modes

Four error strings cover almost every failure, and each points at a different layer. “Specified native messaging host not found” means the browser never found a manifest — wrong name, wrong directory, wrong registry key, or a manifest file that is not valid JSON. “Access to the specified native messaging host is forbidden” means it found the manifest but your origin is not in allowed_origins, often because a development build has a different id. “Native host has exited” means the process launched and then died or closed stdout — a crash, a missing runtime, or a stray write to stdout. “Error when communicating with the native messaging host” means the framing was wrong.

1function classify(message = "") {
2  if (message.includes("not found")) return "not-installed";
3  if (message.includes("forbidden")) return "wrong-extension-id";
4  if (message.includes("has exited")) return "host-crashed";
5  if (message.includes("communicating")) return "protocol-error";
6  return "unknown";
7}

Execution context: the service worker. Map these to user-facing states: “not-installed” should offer the installer download, “wrong-extension-id” is a build problem the user cannot fix, and “host-crashed” deserves a diagnostics link. The step-by-step diagnosis lives in debugging “native host has exited”.

Which layer failed?The four native messaging error strings mapped to the layer that failed — lookup, authorisation, process, or framing — and the first thing to check for each.What did lastError say?not foundLookup failedmanifest location or nameCheck path or registryand JSON validityforbiddenCaller rejectedallowed_originsCompare extension iddev vs store buildhas exitedProcess diedcrash or stdout writeRun host by handread its stderrcommunicatingFraming brokenlength prefix wrongHex-dump stdoutfirst 4 bytes
Read the error as a location: it tells you how far the browser got before giving up.

6. Versioning the protocol

The extension and the host update on completely different schedules. The extension auto-updates within hours of publication; the host updates when the user runs an installer, which may be never. Every message protocol therefore has to tolerate a host that is several versions older — or, after a rollback, newer — than the extension talking to it. The cheapest defence is a handshake: the first message on every connection asks the host for its protocol version and capabilities, and the extension decides what it may send from the answer.

1export async function connectWithHandshake() {
2  const port = hostPort();
3  const hello = await request(port, { type: "hello", protocol: 3 });
4  if (hello.protocol < 2) {
5    port.disconnect();
6    throw new Error("host-too-old");          // prompt the user to update the app
7  }
8  return { port, features: new Set(hello.features ?? []) };
9}

Execution context: the service worker. request here is a small helper that tags each message with an id and resolves when the matching reply arrives — a port delivers replies in whatever order the host sends them. Ship a host that answers hello from its very first release, even if it supports nothing else, so the extension always has a way to ask.

Prefer additive changes: new message types, new optional fields, new entries in features. When a breaking change is unavoidable, have the host accept both the old and new shape for at least one release cycle, and have the extension refuse to send the new shape until the handshake says the host understands it. The same discipline applied to in-browser messages is described in versioning message schemas across updates.

7. Detecting whether the host is installed

Onboarding needs to know whether the native half exists before it asks the user to do anything that depends on it. A sendNativeMessage with a hello payload answers that in one call and classifies the failure if there is one.

1export async function hostStatus() {
2  try {
3    const hello = await chrome.runtime.sendNativeMessage("com.acme.vault", { type: "hello", protocol: 3 });
4    return { installed: true, version: hello.version };
5  } catch (err) {
6    return { installed: false, reason: classify(err.message) };
7  }
8}

Execution context: the service worker, called from the popup or the first-run page through a message. The check launches the host, so do not run it on every popup open; cache the result in chrome.storage.session and re-check when the user clicks “I’ve installed it”. Firefox reports the same failures with slightly different wording, which is why classify matches on fragments rather than whole strings.

8. Closing idle connections

An open native port keeps both the host process and, in Chrome 105 and later, the service worker alive. That is the right trade-off while the user is actively using the feature and the wrong one for the eight hours a day the browser sits idle. Close the port after a period without traffic and reopen it on demand.

1let idleTimer;
2function touch() {
3  clearTimeout(idleTimer);
4  idleTimer = setTimeout(() => { port?.disconnect(); port = null; }, 5 * 60_000);
5}

Execution context: the service worker. Call touch() on every send and every received message. Because the open port is what keeps the worker alive, the setTimeout is guaranteed to fire; after disconnect() the worker becomes eligible for normal idle termination. The host sees stdin close and should exit cleanly.

Cross-cutting concerns: permissions and security

nativeMessaging produces an install warning and invites scrutiny, because the host runs with the user’s full privileges and the extension is its remote control. Treat the boundary as hostile in both directions. The host must validate every message, never execute strings it receives, and never expose a generic “run this command” or “read this path” verb — if a web page can trick the extension into forwarding a message, the host becomes a sandbox escape. The extension must likewise treat host replies as data: never innerHTML them, never use them to construct URLs to navigate to without validation.

Restrict who can reach the host. Keep allowed_origins to your production extension id (and a separate development manifest for development ids), and in the host check the origin argument Chrome passes against an allow-list as a second line of defence. Never forward messages from content scripts to the host without validating the sender, as described in treating content script messages as untrusted.

MV3 constraints box

  • Not in content scripts. connectNative and sendNativeMessage exist only in extension contexts; content scripts must go through the worker.
  • Message size caps. Chrome limits messages from the host to 1 MB and messages to the host to 64 MiB; larger payloads must be chunked or passed by file path.
  • Ports keep the worker alive. From Chrome 105 an open native port prevents idle termination; close it when idle or the worker never sleeps.
  • Absolute paths. On macOS and Linux the host path must be absolute; Windows allows a path relative to the manifest file.
  • No stdout logging. Anything written to stdout outside the framing breaks the connection.
  • Enterprise policy can block hosts. NativeMessagingBlocklist and NativeMessagingAllowlist policies override user installs.

Cross-browser notes

CapabilityChrome / EdgeFirefoxSafari
PermissionnativeMessagingnativeMessagingnativeMessaging
Host manifest key for callersallowed_originsallowed_extensionsNot used
RegistrationRegistry (Win) / directoriesRegistry (Win) / directoriesContaining app bundle
Arguments passed to hostOrigin, --parent-window on WindowsManifest path, add-on id—
connectNativeYesYesLimited; prefer sendNativeMessage
Host processSeparate executableSeparate executableApp extension handler in Swift

The portable architecture keeps the protocol — message types, validation, versioning — in one shared module used by both the extension and the host, and isolates per-browser differences in the registration step. Safari is the outlier, and native messaging in Firefox and Safari covers what changes.

What this section covers

Begin with the native messaging wire format, because every other bug in this area traces back to framing. Writing a native messaging host in Node builds a robust host; registering native hosts on Windows, macOS and Linux puts it where browsers look; debugging “native host has exited” fixes the error everyone meets; native messaging in Firefox and Safari covers the other engines; and shipping a native host with an installer gets it onto users’ machines.

Not covered here: messaging between extensions, which is cross-extension and native messaging in the messaging topic, and talking to a remote server over HTTP, which is network requests and backend sync.