In modern Manifest V3 Chrome Extension development, security is paramount. Web browsers enforce a strict boundary between different execution contexts: content scripts (which run in the context of the web page and can touch the DOM) and service workers (formerly background scripts, which run in isolation and have access to the full chrome.* APIs).

Content scripts cannot directly make cross-origin network calls (CORS restrictions), query other tab states, or read secure storage. To execute these operations safely, we must implement robust Inter-Process Communication (IPC) via message passing. In this post, we'll design a secure message channel between content scripts and service workers.


The Message Broker Architecture

To pass data safely, our extension implements a secure message broker pattern:

  1. Content Script (Emitter): Listens for page gestures, harvests page metadata, and packs the values into a structured message payload. It calls chrome.runtime.sendMessage to emit the package.
  2. Service Worker (Broker): Listens for incoming packages via chrome.runtime.onMessage.addListener, evaluates the payload type, executes secure API requests or cookie queries, and returns a response payload.
  3. Content Script (Receiver): Processes the returning response inside a secure callback to update the page UI.

Step 1: Emitting Messages from Content Scripts

Here is how a content script harvests DOM details and passes them as an initialized package to the background service worker:

// content_script.js
function sendCurationPayload() {
    const payload = {
        message: "initiate_curation",
        url: window.location.href,
        title: document.title,
        selected_text: window.getSelection ? window.getSelection().toString() : ""
    };

    console.log("[content_script.js] Sending payload to broker:", payload);
    
    // Send to background service worker
    chrome.runtime.sendMessage(payload, function(response) {
        if (chrome.runtime.lastError) {
            console.error("Broker connection failed:", chrome.runtime.lastError.message);
            return;
        }
        console.log("[content_script.js] Received response from broker:", response);
    });
}

Step 2: Processing Messages in the Service Worker

In Manifest V3, background tasks are handled by a service worker (background.js). It registers a listener, handles asynchronous tasks securely, and responds using sendResponse (ensuring that return true; is called to keep the message channel open during async actions):

// background.js
chrome.runtime.onMessage.addListener(function (message, sender, sendResponse) {
    console.log("[background.js] Incoming message intercepted:", message);

    if (message.message === "initiate_curation") {
        // Execute an async API request to store the curation data
        storeCurationRecord(message)
            .then(data => {
                // Send success response back to content script
                sendResponse({ status: "success", data: data });
            })
            .catch(error => {
                sendResponse({ status: "error", error: error.message });
            });
            
        return true; // Keep connection open for async sendResponse callback!
    }

    if (message.message === "open_new_tab") {
        chrome.tabs.create({ url: message.url });
        sendResponse({ status: "tab_opened" });
    }
});

async function storeCurationRecord(payload) {
    const response = await fetch("https://api.yourportal.com/curate/", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(payload)
    });
    return response.json();
}

Key Security & IPC Best Practices

  1. Return True for Async Tasks: In Manifest V3, sendResponse becomes invalid as soon as the listener function finishes. You must add return true; to tell Chrome that you intend to respond asynchronously, preventing immediate channel closures.
  2. Verify Sender Origin: In background listeners, always inspect the sender object to ensure that the message was enqueued from a valid content script inside your extension, preventing external injection.
  3. Handle runtime.lastError: Always check chrome.runtime.lastError inside your callback listeners. If the service worker is idle or sleeping, message channels can time out, throwing errors that will crash your scripts if unhandled.

Implementing secure, asynchronous inter-process communication ensures that your Chrome extension safely bridges page DOM manipulations with privileged backend API operations.