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.
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
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.
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.
Cross-browser variation
- Chrome / Edge:
externally_connectablesupports bothmatchesandids.sender.originis populated for external messages; for internal ones,sender.tabdistinguishes a content script from an extension page. - Firefox: supports
externally_connectablefor 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 useonMessageExternal. That is a downgrade in safety, and the verb allowlist matters more there. - Safari:
externally_connectableis 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
postMessageto a particular script — the platform simply does not carry that information.
Verification
- 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.
- 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.
- On a page with your bridge, post a privileged verb from the console and confirm nothing happens.
- 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.
Related
- Sanitising untrusted page data in an extension — validating the payloads these channels carry.
- Cross-extension and native messaging — the legitimate uses of the external channels.
- Handling API keys without shipping them in the bundle — secrets a bridge must never expose.
- Extension security and CSP hardening — the parent guide.