Native Messaging in Firefox and Safari

Port a Chrome native messaging integration to Firefox and Safari: allowed_extensions and gecko ids, Firefox's launch arguments, and Safari's SafariWebExtensionHandler in the containing macOS app.

Published October 2, 2026 Updated October 2, 2026 8 min read
Table of Contents

The Chrome version of your native integration works. The Firefox build of the same extension fails with “No such native application”, and the Safari build has no idea where to send anything because there is no host manifest to find. The extension-side API is nearly identical across engines; what differs is how the browser identifies your extension to the host and, in Safari’s case, what the host even is. This guide walks through both ports. It belongs to native messaging and host integration.

Why the same code reaches different hosts

Chrome identifies callers by extension origin — chrome-extension://<32 letters>/ — and so its host manifest lists allowed_origins. Firefox identifies add-ons by the id you declare in browser_specific_settings.gecko.id, so its manifest lists allowed_extensions and lives in Mozilla’s directories. Safari takes a different approach entirely: a Safari web extension always ships inside a macOS (or iOS) app, and native messages are delivered to a Swift class in that app’s extension target. There is no separate executable, no manifest lookup and no stdio — the containing app is the host, and the name you pass to sendNativeMessage is effectively ignored.

Where a native message goes in each engineChrome and Firefox look up a host manifest and launch an executable over stdio; Safari routes the message to SafariWebExtensionHandler inside the containing app.Chromeallowed_originsHost manifestChrome dirs / registryExecutablestdio framessame protocol, different identityFirefoxallowed_extensionsHost manifestMozilla dirs / registryExecutablestdio framesno process at allSafariany nameApp extensionNSExtensionRequestHandlingSwift handlerdictionary in, out
Two engines launch a process; the third calls into code that ships with the extension.

Step-by-step: Firefox

1. Give the add-on a fixed id

1// manifest.json (Firefox build)
2{
3  "manifest_version": 3,
4  "browser_specific_settings": {
5    "gecko": { "id": "vault@acme.example", "strict_min_version": "115.0" }
6  },
7  "permissions": ["nativeMessaging"]
8}

Execution context: the Firefox manifest. Without an explicit id, Firefox assigns a random one to temporary add-ons on each load, and no host manifest can name it. The id is an email-like string or a UUID in braces; it stays the same across AMO releases and is what allowed_extensions must contain.

2. Write the Firefox host manifest

1{
2  "name": "com.acme.vault",
3  "description": "Acme Vault native bridge",
4  "path": "/opt/acme-vault/vault-host",
5  "type": "stdio",
6  "allowed_extensions": ["vault@acme.example"]
7}

Execution context: a file in Mozilla’s native-messaging directory (~/.mozilla/native-messaging-hosts/ on Linux, ~/Library/Application Support/Mozilla/NativeMessagingHosts/ on macOS) or pointed to by HKCU\Software\Mozilla\NativeMessagingHosts\com.acme.vault on Windows. Firefox rejects a manifest that contains allowed_origins instead — the keys are not interchangeable — so keep two files even if everything else is identical.

3. Handle Firefox’s launch arguments in the host

1// host: identify the caller in either browser
2const args = process.argv.slice(2);
3const caller = args[0]?.startsWith("chrome-extension://")
4  ? { browser: "chromium", id: args[0] }
5  : { browser: "firefox", manifestPath: args[0], id: args[1] };
6
7const ALLOWED = new Set(["chrome-extension://abcdefghijklmnopabcdefghijklmnop/", "vault@acme.example"]);
8if (!ALLOWED.has(caller.id)) { process.stderr.write(`refusing caller ${caller.id}\n`); process.exit(1); }

Execution context: the host process. Chrome passes the origin first; Firefox passes the host manifest path and then the add-on id. Checking the caller in the host is defence in depth on top of the manifest’s allow-list. On Windows, Chrome adds --parent-window=…, which this parsing ignores harmlessly.

4. Read errors from port.error

1const port = browser.runtime.connectNative("com.acme.vault");
2port.onDisconnect.addListener((p) => {
3  console.warn("host disconnected:", p.error?.message ?? "clean");
4});

Execution context: the Firefox background context (an event page or service worker, depending on your MV3 target). Firefox reports the disconnect reason on the port object, not on runtime.lastError. The host’s stderr appears in the Browser Console, which makes Firefox the easier engine to debug a new host in.

Chrome versus Firefox host integrationDifferences between Chrome and Firefox in caller identity, host manifest key, launch arguments and where errors are reported.AspectChromeFirefoxCaller identityExtension origingecko idManifest keyallowed_originsallowed_extensionsargvorigin [, --parent-window]manifest path, idDisconnect reasonruntime.lastErrorport.errorHost stderr--enable-loggingBrowser Console
Same frames, same API — different identity and different paperwork.

Step-by-step: Safari

5. Implement the handler in the app extension

Xcode’s Safari Web Extension template generates SafariWebExtensionHandler.swift. Messages from sendNativeMessage arrive in beginRequest.

 1import SafariServices
 2
 3class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling {
 4    func beginRequest(with context: NSExtensionContext) {
 5        let item = context.inputItems.first as? NSExtensionItem
 6        let message = item?.userInfo?[SFExtensionMessageKey] as? [String: Any] ?? [:]
 7
 8        var reply: [String: Any] = ["error": ["code": "unknown-type"]]
 9        if message["type"] as? String == "hello" {
10            reply = ["result": ["protocol": 3, "version": "2.0.0"]]
11        }
12
13        let response = NSExtensionItem()
14        response.userInfo = [SFExtensionMessageKey: reply]
15        context.completeRequest(returningItems: [response], completionHandler: nil)
16    }
17}

Execution context: the app extension process, sandboxed under the containing app’s entitlements and launched by Safari on demand. There is no stdio and no length prefix — messages are dictionaries. The handler must call completeRequest exactly once; a missing call leaves the extension’s promise pending. Use an App Group container if the handler needs to share data with the main app.

6. Call it from the extension

1// Works in Safari; the application id argument is not used for routing
2const reply = await browser.runtime.sendNativeMessage("application.id", { type: "hello" });

Execution context: the Safari extension’s background context or an extension page. Safari routes every native message to the containing app regardless of the string you pass, but a non-empty string is still required. connectNative exists in recent Safari versions, but push-style messaging from the app to the extension is far more constrained than a Chrome port; for request–reply patterns, sendNativeMessage is the reliable path.

A Safari native message round tripThe extension calls sendNativeMessage; Safari launches or wakes the app extension, which receives the dictionary in beginRequest and completes the request with a reply dictionary.Extension JSSafariApp extensionsendNativeMessage(…, {type:hello})beginRequest(context)launch if neededread SFExtensio…completeRequest([reply])promise resolves
The containing app's extension target is the host — installing the app installs it.

7. Share the protocol across all three

Keep message types and validation in one JavaScript module used by the extension and the Node host, and mirror it in Swift with the same type names and error codes. A single hello handshake that every host answers lets the extension discover which features each platform’s host supports without branching on the browser.

1// shared/protocol.js
2export const PROTOCOL = 3;
3export const TYPES = Object.freeze({ HELLO: "hello", UNLOCK_STATE: "unlockState", READ_ITEM: "readItem" });
4export function isReply(x) { return x && typeof x === "object" && ("result" in x || "error" in x); }

Execution context: a module imported by the extension and bundled into the Node host. Swift cannot import it, so add a unit test on the Swift side that asserts the same constants — protocol drift between hosts is the most common cross-engine bug.

8. Decide what each build ships

The three builds end up with different native footprints, and the extension’s UI should reflect that rather than pretend they are identical. A Chrome or Firefox user installs a separate host download; a Safari user already has the host the moment the extension exists, because both came in the same app. That changes onboarding: the install card from the Chrome build never appears in Safari, and a Safari “host not responding” error means the app extension threw, not that something is missing.

1const engine = globalThis.browser?.runtime?.getURL("").startsWith("safari-web-extension:")
2  ? "safari"
3  : globalThis.browser?.runtime?.getURL("").startsWith("moz-extension:") ? "firefox" : "chromium";
4
5export const needsSeparateHostInstall = engine !== "safari";

Execution context: any extension context. The extension URL scheme is a dependable engine signal that does not depend on the user agent. Use it only for decisions that are genuinely about packaging — feature support should still be detected by calling the API and handling the error.

Cross-browser variation

  • Chrome / Edge: origin-based identity, separate executable, lastError for errors. See registering native hosts on Windows, macOS and Linux.
  • Firefox: gecko-id identity, separate executable, port.error for errors, excellent stderr visibility. Supported on Windows, macOS and Linux; not on Firefox for Android.
  • Safari: containing-app identity, Swift handler, no stdio. Available on macOS; on iOS and iPadOS the same handler model applies, within tighter app-extension limits.

Verification

  1. Firefox: load the add-on from about:debugging, confirm the id shown matches allowed_extensions, then run await browser.runtime.sendNativeMessage("com.acme.vault", {type:"hello"}) in its console.
  2. Safari: build and run the containing app from Xcode, enable the extension in Safari’s settings, open the extension’s background page inspector from the Develop menu, and run the same call with any application id.
  3. In each, confirm the reply’s protocol field matches the extension’s expected version.

FAQ

Can Safari launch the same Node host my other builds use?

Not directly from the extension. The Swift handler could launch a bundled helper and talk to it, but App Sandbox restrictions make that complex; most teams reimplement the small host surface in Swift.

Do Firefox temporary add-ons work with native messaging?

Yes, provided the manifest declares a fixed gecko.id. Without it the temporary id changes on every load.

Does Firefox support sendNativeMessage and connectNative?

Both, with the same semantics as Chrome. Firefox also kills the host process when the port disconnects.

Other Core APIs & Cross-Browser Data Management Resources