πŸ” [Tutorial Part 2] Securing Your Chrome Extension β†’ n8n (HMAC & Native Messaging)

:waving_hand: Welcome to the second installment of our Browser-to-Automation series!

Before You Start


Why This Matters

In Part 1, the extension called an n8n webhook directly.

That meant anyone who found the URL could trigger the workflow and consume AI credits.

There is another important rule here: secrets should not live in extension JavaScript, because the client can inspect them.

To solve this, we use:

  1. Chrome Native Messaging

  2. A local Python signer

  3. HMAC verification in n8n

The secret never leaves your machine.


Step 1: UI Evolution (Options and History)

This version adds an Options page for configuration and a History view for previous translations.

That makes the extension feel more complete, but it also means the background script now plays a bigger role. It becomes the coordinator for settings, request signing, and data flow before anything reaches n8n.


Step 2: Native Messaging Host (Python)

This script runs locally and signs requests using a local shared secret.

1. Create a secret file

Create a file named secret with no extension in your script folder.

Inside it, paste your secret: <your_shared_secret>

2. Create secure_signer.py

import os
import sys
import json
import struct
import hmac
import hashlib

BASE_DIR = os.path.dirname(os.path.abspath(__file__))
SECRET_PATH = os.path.join(BASE_DIR, "secret")


def get_shared_secret():
    if not os.path.exists(SECRET_PATH):
        return None
    with open(SECRET_PATH, "r", encoding="utf-8") as f:
        return f.read().strip()


SHARED_SECRET = get_shared_secret()


def get_message():
    raw_length = sys.stdin.buffer.read(4)
    if not raw_length:
        sys.exit(0)
    message_length = struct.unpack("=I", raw_length)[0]
    return json.loads(sys.stdin.buffer.read(message_length).decode("utf-8"))


def send_message(message):
    content = json.dumps(message).encode("utf-8")
    sys.stdout.buffer.write(struct.pack("=I", len(content)))
    sys.stdout.buffer.write(content)
    sys.stdout.buffer.flush()


def main():
    if not SHARED_SECRET:
        return

    while True:
        try:
            msg = get_message()
            p = msg.get("payload", {})

            # Canonical signing format (JSON array for consistency)
            message_to_sign = json.dumps(
                [p.get("timestamp", ""), p.get("language", ""), p.get("text", "")],
                ensure_ascii=False,
                separators=(",", ":")
            )

            signature = hmac.new(
                SHARED_SECRET.encode("utf-8"),
                message_to_sign.encode("utf-8"),
                hashlib.sha256
            ).hexdigest()

            send_message({
                "signature": signature,
                "timestamp": p.get("timestamp")
            })
        except EOFError:
            break


if __name__ == "__main__":
    main()

Step 3: Chrome Extension (background.js)

The extension asks Python for a signature, then sends the signed request to n8n.

async function handleTranslation(payload, sendResponse) {
  try {
    const timestamp = Date.now().toString();

    // 1. Ask Python to sign the payload
    const sigResponse = await new Promise((resolve) => {
      chrome.runtime.sendNativeMessage('com.secure.n8n.signer', {
        payload: { ...payload, timestamp: timestamp }
      }, resolve);
    });

    if (!sigResponse?.signature) {
      throw new Error("Signing failed");
    }

    // 2. Send signed request to n8n
    const response = await fetch("http://localhost:5678/webhook/secure-translate", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "X-Signature": sigResponse.signature,
        "X-Timestamp": timestamp
      },
      body: JSON.stringify(payload)
    });

    const result = await response.json();
    sendResponse({ success: true, translatedText: result.translatedText });
  } catch (err) {
    sendResponse({ success: false, error: err.message });
  }
}

Step 4: n8n Backend (Verification)

1. Webhook (CORS)

  • Allowed Origins: chrome-extension://<YOUR_EXTENSION_ID>

This ensures only your extension can call the webhook from the browser.

2. Crypto Node (HMAC Verification)

Both sides must sign the exact same string.

We use a JSON array for consistency.

  • Value:
={{ JSON.stringify([$json.headers["x-timestamp"], $json.body.language, $json.body.text]) }}
  • Secret: Same as your Python secret file

3. IF Node (Gatekeeper)

Compare the request signature with the computed signature.

4. Respond to Webhook

Always return clean JSON to avoid β€œUnexpected end of JSON” errors:

{
  "translatedText": "={{ $node['AI Translator'].json.text }}"
}


What Actually Secures This?

It is not just CORS. Your real protection is:

  1. The secret is never exposed in the frontend.

  2. The payload is signed locally through Native Messaging.

  3. n8n verifies the signature before any AI processing happens.


Design Considerations: Choosing the Right Security Model

When designing this solution, I intentionally prioritized Zero-Trust security principles over ease of installation. Like most engineering decisions, this comes with trade-offs. Understanding those trade-offs helps you decide when the Chrome Native Messaging + n8n HMAC model is the right choice.

Why this model is strong (Security First Approach)

Secrets stay outside the browser environment

By using a local Python Native Host, your API keys, signing secrets, and sensitive logic do not live inside the extension itself.

That means they are far less exposed to risks such as:

  • XSS (Cross-Site Scripting)
  • malicious browser extensions
  • local storage scraping
  • accidental credential leakage in client side code

Strong trust boundaries

The browser extension becomes a lightweight interface, while trust-sensitive operations happen in the local host process and your n8n backend.

This follows a cleaner separation of responsibilities:

  • Extension: UI, trigger layer
  • Native Host: secure local bridge
  • n8n: automation engine

Well suited for internal or enterprise tools

This model can be an excellent fit for:

  • internal company tools
  • DevOps utilities
  • admin workflows
  • regulated environments
  • teams handling sensitive credentials

IT teams can deploy the Native Host and extension in a controlled way, allowing staff to trigger automations without directly accessing secrets.

The Trade off (User Friction)

Extra installation steps

Unlike a standard one click extension, this setup usually requires:

  • installing Python or a packaged host app
  • placing the Native Messaging manifest
  • OS-level registration
  • occasional update management

Smaller target audience

For public consumer facing extensions, this creates a higher barrier to entry. Non technical users may abandon setup before reaching value.

The Verdict

If you are building for:

  • yourself
  • technical users
  • internal teams
  • security sensitive environments

then the Native Messaging + HMAC model is a robust and professional choice.

If you are building for the general public at scale, a Cloud Proxy / Middleware model is often better for onboarding and distribution, while keeping secrets server-side.

Final Engineering Perspective

Good architecture is rarely about β€œbest” in absolute terms. It is about choosing the right balance between:

  • security
  • usability
  • maintenance
  • scale
  • audience expectations

In this case, I chose security first.

What’s Next?

In Part 3, we will build:

  • Error handling

  • Retry logic

  • Failure-safe UX

Drop a comment if you run into any issues with the Native Messaging setup.

2 Likes

Hi everyone :waving_hand:

I Updated the article with a new section on architectural trade-offs; security vs user friction, and when Native Messaging is the right model.