Protecting Extension Messages from Web Pages

Stop web pages talking to your MV3 service worker — externally_connectable, verifying sender, window.postMessage bridges, and why onMessageExternal needs an allowlist.

Published September 18, 2026 Updated September 18, 2026 8 min read
Table of Contents

By default a web page cannot send a message to your extension — but three common patterns open a door, and two of them look like ordinary application code. externally_connectable invites named sites in; a window.postMessage bridge in a content script invites every script on the page in, including the ones you did not write. Knowing which listener can hear whom is the whole of this topic. This guide is part of extension security and CSP hardening.

Who can reach which listener

Message entry points and who can use themFour inbound channels — runtime.onMessage, onMessageExternal, a postMessage bridge and onConnectExternal — with who may send on each and what identifies the sender.ChannelWho can sendSender identityDefault stateruntime.onMessageYour own contextssender.id, sender.tabAlways onruntime.onMessageExternalListed sites and extensio…sender.origin / sender.idOff unless declaredpostMessage bridgeAny script in the pageNoneOn if you wrote oneruntime.onConnectExternalListed sites and extensio…port.senderOff unless declared
The bridge row is the dangerous one: nothing distinguishes your page script from any other script in that page.

Step-by-step

1. Check sender on every internal message

runtime.onMessage can be reached by your own content scripts, which run on pages you do not control. A content script is not compromised by the page, but it can be fed by it — so the worker should still confirm the message is shaped as expected and came from where it claims.

1chrome.runtime.onMessage.addListener((msg, sender, respond) => {
2  if (sender.id !== chrome.runtime.id) return false;      // not ours at all
3  const fromContentScript = !!sender.tab;
4  if (msg.type === "admin:reset" && fromContentScript) return false;  // page-reachable path
5  // …dispatch…
6});

Execution context: the service worker, registered at the top level. sender.id is set by the browser and cannot be spoofed by the message body. The second check is the useful one: privileged operations should be reachable only from your own extension pages, never from a content script running on an arbitrary site.

2. Declare externally_connectable narrowly, or not at all

1{
2  "externally_connectable": {
3    "matches": ["https://app.example.com/*"],
4    "ids": ["abcdefghijklmnopabcdefghijklmnop"]
5  }
6}

Execution context: parsed at install. Without this key, onMessageExternal can never fire from a web page. With "matches": ["*://*/*"] — which some tutorials still show — every site on the internet can message your worker, and reviewers treat it as a red flag.

3. Verify the origin inside the external handler too

The manifest restricts who can connect; the handler should still confirm what it got.

1const ALLOWED = new Set(["https://app.example.com"]);
2
3chrome.runtime.onMessageExternal.addListener((msg, sender, respond) => {
4  if (!sender.origin || !ALLOWED.has(sender.origin)) return false;
5  if (msg?.type !== "site:link") return false;
6  respond({ ok: true, version: chrome.runtime.getManifest().version });
7  return true;
8});

Execution context: the service worker. sender.origin is provided by the browser for external messages and is the value to check — sender.url includes the path and is easy to match too loosely.

4. Treat a postMessage bridge as a public API

If a content script relays window.postMessage into the extension, every script on that page can send. That includes third-party tags and anything injected by another extension.

 1// content script — the bridge, done as carefully as it can be
 2const EXPECTED_ORIGIN = location.origin;
 3
 4window.addEventListener("message", (e) => {
 5  if (e.source !== window) return;                  // not from this document
 6  if (e.origin !== EXPECTED_ORIGIN) return;         // not from this origin
 7  const msg = e.data;
 8  if (msg?.channel !== "myext" || typeof msg.type !== "string") return;
 9  if (!PAGE_ALLOWED.has(msg.type)) return;          // explicit allowlist of verbs
10  chrome.runtime.sendMessage({ type: msg.type, payload: sanitise(msg.payload) });
11});

Execution context: the content script’s isolated world. None of these checks identify the sender script, because the platform provides no way to — they only narrow the window and the origin. The allowlist is therefore the real control: a bridge should expose two or three harmless verbs, never a generic pass-through.

5. Never relay privileged operations

The verbs a bridge exposes should be the ones you would be comfortable putting on a public HTTP endpoint with no authentication.

1const PAGE_ALLOWED = new Set(["ping", "getPublicStatus"]);
2// Not: "settings:write", "auth:token", "history:read", "permissions:request"

Execution context: the content script. If the page needs something privileged, the right shape is a user gesture in your own UI — a popup button — not a message the page can send whenever it likes.

How a hostile script reaches the worker through a bridgeA third-party tag on the page posts a message the bridge accepts because it cannot distinguish senders, and the worker acts on it as if it came from the site's own code.Third-party tagPage windowContent scriptService workerpostMessage({channel:'myext', …})e.source === window ✓same documente.origin === location.origin ✓verb in allowlist?sendMessage(type, payload)acts with …
Every check in the bridge passes — origin, source and shape are all correct. Only the verb allowlist stops it.

Deciding whether the channel should exist

Before hardening a web-page channel it is worth asking whether it should exist at all, because the safest version of this feature is often the absent one.

The usual justification is a companion website that wants to know whether the extension is installed, or wants to hand it a document to open. Both have alternatives that need no inbound channel.

Detection without a channel. The extension can announce itself to pages it already runs on, by setting a marker the page can read. The page learns the extension exists; the extension exposes no verbs.

1// content script on your own site only
2document.documentElement.dataset.myextVersion = chrome.runtime.getManifest().version;

Execution context: the content script’s isolated world, writing into the shared DOM. This is deliberately one-directional: the page can read the attribute and cannot send anything back.

Handoff without a channel. Rather than the page pushing a document to the extension, have the page link to an extension page with the document identifier in the URL, and let the user click it.

1// The link the site renders, resolved from the marker above.
2const url = `chrome-extension://${EXT_ID}/pages/import.html?doc=${encodeURIComponent(id)}`;

Execution context: the website’s own code. The user’s click is the authorisation, the identifier is visible in the URL, and no listener is added to the extension at all. The extension page still validates doc before acting on it.

Where a real channel is genuinely needed — a first-party web application that legitimately drives the extension — declare externally_connectable for that exact origin and keep the verb list short. What should almost never happen is a postMessage bridge added for convenience on a site whose other scripts you do not control.

Does the page need to talk to the extension?A decision tree separating installation detection, user-initiated handoff and a genuine first-party integration, with the mechanism each should use.What is the website actually trying to do?Detect the extensionDOM markercontent script writes, page readsNo inbound channelnothing to attackHand over a documentA link the user clicksextension page + query paramValidate the parameterit is page-controlledDrive the extensionexternally_connectableone exact originShort verb allowlistcheck sender.origin too
Only the third branch needs an inbound listener — the first two are solved without one.

Cross-browser variation

  • Chrome / Edge: externally_connectable supports both matches and ids. sender.origin is populated for external messages; for internal ones, sender.tab distinguishes a content script from an extension page.
  • Firefox: supports externally_connectable for extension-to-extension messaging; support for web-page connections has been more limited, so a Firefox build may need the bridge pattern where Chrome would use onMessageExternal. That is a downgrade in safety, and the verb allowlist matters more there.
  • Safari: externally_connectable is not reliably supported. Design the feature so the web-page channel is optional rather than required.
  • All three: another extension can inject scripts into the same page and post to your bridge. There is no way to attribute a postMessage to a particular script — the platform simply does not carry that information.

Verification

  1. Confirm no page can reach the worker unannounced. From an ordinary site’s console:
1chrome.runtime.sendMessage("<your-extension-id>", { type: "ping" });
2// TypeError: Cannot read properties of undefined — or no response

Execution context: a web page’s own console on a site not listed in externally_connectable. A reply here means the manifest is broader than you think.

  1. From a listed origin, confirm the allowed verb works and an unlisted verb is ignored rather than erroring informatively — an error message that names valid verbs is itself a disclosure.
  2. On a page with your bridge, post a privileged verb from the console and confirm nothing happens.
  3. Grep the built bundle for addEventListener("message" and confirm every hit has origin, source and allowlist checks.

FAQ

Is sender.tab enough to know a message came from my content script?

It tells you the message came from a tab rather than from an extension page. It does not tell you the content script was not fed its payload by the page — so combine it with validation of the payload itself.

Can another extension send to my runtime.onMessage?

No. Cross-extension messages arrive on onMessageExternal and only if your externally_connectable.ids lists the sender. sender.id !== chrome.runtime.id is nonetheless a cheap assertion to keep.

Should the bridge sign its messages?

A shared secret in a content script is readable by anyone who unpacks the extension, so it buys nothing against a determined attacker. Spend the effort on narrowing the verbs instead.

Other MV3 Architecture & Extension Lifecycle Resources