Chrome Extension Security Hardening: Protect User Data and Prevent Tampering (2026)

AppBooster Team · · 10 min read
Cybersecurity lock and shield concept

Chrome extensions sit in a uniquely privileged position: they run alongside every tab a user opens, handle sensitive form data, intercept network requests, and often hold authentication tokens. That privilege is exactly what makes them a high-value target. In 2026, supply-chain attacks on browser extensions are a known attack vector, and the Chrome Web Store security review process has grown stricter as a result.

This guide walks through the concrete hardening steps every extension developer should apply — from locking down your Content Security Policy to encrypting data at rest and validating every inter-component message.


1. The Extension Threat Landscape in 2026

Three attack classes dominate real-world extension compromises:

  • Supply-chain injection — a malicious npm dependency or a compromised build pipeline injects code into your extension bundle before it ships.
  • Message spoofing — a rogue web page or another extension sends crafted messages to your content script or service worker to trigger privileged actions.
  • Storage exfiltrationchrome.storage.local is readable by any script running in your extension’s origin, so a single XSS flaw exposes every stored secret.

Manifest V3 closes several old attack surfaces (no remote code execution, no eval by default) but it does not make extensions automatically safe. You still own the hardening work.


2. Content Security Policy for Manifest V3

MV3 enforces a default CSP on extension pages, but the default is not the tightest possible configuration. Define your own explicitly in manifest.json:

{
  "manifest_version": 3,
  "content_security_policy": {
    "extension_pages": "default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self';"
  }
}

Key directives to understand:

DirectiveRecommended valueWhy
script-src'self'Blocks inline scripts and remote script loads
object-src'none'No Flash/plugin execution surface
base-uri'none'Prevents base-tag hijacking
form-action'self'Stops forms from posting to attacker-controlled endpoints

Never add 'unsafe-inline' or 'unsafe-eval'. If a library you depend on requires either, find an alternative — those directives erase most of the CSP benefit.

For extensions that need to fetch from a specific API:

"extension_pages": "default-src 'self'; script-src 'self'; connect-src https://api.yourservice.com; img-src 'self' data:; object-src 'none'; base-uri 'none';"

Enumerate hosts explicitly. Wildcard origins in connect-src are a common misconfiguration.


3. Message Passing Validation

The messaging layer between content scripts and the service worker is a trust boundary. Treat every incoming message as untrusted input.

Validate sender origin

// service-worker.js
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  // Reject messages not originating from your own extension pages or content scripts
  if (sender.id !== chrome.runtime.id) {
    console.warn("Rejected message from unknown sender:", sender.id);
    return false;
  }

  // Content scripts run in web pages — validate the tab URL if the action is sensitive
  if (sender.tab && !isAllowedOrigin(sender.tab.url)) {
    console.warn("Rejected message from disallowed origin:", sender.tab.url);
    return false;
  }

  handleMessage(message, sendResponse);
  return true; // keep channel open for async response
});

function isAllowedOrigin(url) {
  try {
    const { hostname } = new URL(url);
    // Replace with your actual list of host_permissions
    const allowed = ["trusted-site.com", "another-site.com"];
    return allowed.some(h => hostname === h || hostname.endsWith("." + h));
  } catch {
    return false;
  }
}

Validate message shape

Never act on a raw message object. Define a schema and validate before dispatching:

const ALLOWED_ACTIONS = new Set(["fetchData", "clearCache", "getAuthToken"]);

function handleMessage(message, sendResponse) {
  if (
    typeof message !== "object" ||
    message === null ||
    typeof message.action !== "string" ||
    !ALLOWED_ACTIONS.has(message.action)
  ) {
    sendResponse({ error: "Invalid message format" });
    return;
  }

  switch (message.action) {
    case "fetchData":
      handleFetchData(message.payload, sendResponse);
      break;
    case "clearCache":
      handleClearCache(sendResponse);
      break;
    case "getAuthToken":
      handleGetAuthToken(sendResponse);
      break;
  }
}

For chrome.runtime.onMessageExternal (messages from other extensions or web pages), apply the same validation and additionally check the sender origin against an explicit allowlist.


4. Secure Storage Patterns

chrome.storage.local is not encrypted at rest by the browser. Anyone who can execute JavaScript in your extension’s context — including a successful XSS attacker — can read it in full.

Encrypt sensitive values before storing

Use the Web Crypto API, which is available in both service workers and extension pages:

// crypto-utils.js
const ALGORITHM = { name: "AES-GCM", length: 256 };

async function getOrCreateKey() {
  const stored = await chrome.storage.local.get("_encKey");
  if (stored._encKey) {
    return await crypto.subtle.importKey(
      "raw",
      base64ToBuffer(stored._encKey),
      ALGORITHM,
      false,
      ["encrypt", "decrypt"]
    );
  }
  const key = await crypto.subtle.generateKey(ALGORITHM, true, ["encrypt", "decrypt"]);
  const exported = await crypto.subtle.exportKey("raw", key);
  await chrome.storage.local.set({ _encKey: bufferToBase64(exported) });
  return key;
}

export async function encryptValue(plaintext) {
  const key = await getOrCreateKey();
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const encoded = new TextEncoder().encode(plaintext);
  const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, encoded);
  return bufferToBase64(iv) + "." + bufferToBase64(ciphertext);
}

export async function decryptValue(stored) {
  const key = await getOrCreateKey();
  const [ivB64, ctB64] = stored.split(".");
  const iv = base64ToBuffer(ivB64);
  const ciphertext = base64ToBuffer(ctB64);
  const plainBuffer = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext);
  return new TextDecoder().decode(plainBuffer);
}

function bufferToBase64(buf) {
  return btoa(String.fromCharCode(...new Uint8Array(buf)));
}

function base64ToBuffer(b64) {
  return Uint8Array.from(atob(b64), c => c.charCodeAt(0)).buffer;
}

Store tokens and credentials using encryptValue before calling chrome.storage.local.set. Note: the encryption key itself lives in storage too — this protects against offline database reads, not against a fully compromised extension runtime. For truly high-sensitivity data, require the user to supply a passphrase to derive the key via PBKDF2.

Minimize what you store

Apply data minimization aggressively. If a token expires in one hour, clear it after use. Use chrome.storage.session (cleared when the browser closes) for short-lived state instead of chrome.storage.local.


5. Preventing XSS in Extension Pages

Extension pages (popup, options, side panel) render HTML you control, but they often display user-supplied content or data fetched from external APIs. That data must never be interpolated raw into the DOM.

Wrong:

document.getElementById("output").innerHTML = userData.name; // XSS risk

Right:

const el = document.getElementById("output");
el.textContent = userData.name; // safe — treated as text, not markup

When you need to render richer content, build the DOM programmatically or use a sanitizer:

import DOMPurify from "dompurify";

function renderSafeHtml(container, htmlString) {
  const clean = DOMPurify.sanitize(htmlString, {
    ALLOWED_TAGS: ["b", "i", "em", "strong", "a", "p"],
    ALLOWED_ATTR: ["href", "target"],
    ALLOW_DATA_ATTR: false,
  });
  container.innerHTML = clean;
}

DOMPurify works in extension pages. Pin its version and verify the package hash in your lockfile.


6. External Resource Validation and Subresource Integrity

If your extension loads any static asset from a CDN (fonts, icons, a shared stylesheet), use Subresource Integrity (SRI) to ensure the file has not been tampered with:

<link
  rel="stylesheet"
  href="https://cdn.example.com/styles.css"
  integrity="sha384-<hash-here>"
  crossorigin="anonymous"
/>

Generate the hash during your build process:

# Generate SRI hash for a file
openssl dgst -sha384 -binary styles.css | openssl base64 -A

For fetch calls inside your service worker, validate the response yourself when SRI is unavailable:

async function fetchWithIntegrityCheck(url, expectedHash) {
  const response = await fetch(url);
  const buffer = await response.arrayBuffer();
  const hashBuffer = await crypto.subtle.digest("SHA-384", buffer);
  const actualHash = bufferToBase64(hashBuffer);
  if (actualHash !== expectedHash) {
    throw new Error(`Integrity check failed for ${url}`);
  }
  return buffer;
}

7. OAuth Token Handling Best Practices

Extensions that use chrome.identity.getAuthToken or launchWebAuthFlow must treat tokens as secrets:

  • Never log tokens. Strip them from any console output or error reporting.
  • Never send tokens in URL query strings. Use Authorization: Bearer headers instead.
  • Revoke tokens on sign-out. Call chrome.identity.removeCachedAuthToken and hit the provider’s revocation endpoint.
  • Use short-lived tokens. Request offline access and refresh tokens only when the feature genuinely requires background sync.
async function makeAuthenticatedRequest(endpoint) {
  const token = await new Promise((resolve, reject) => {
    chrome.identity.getAuthToken({ interactive: false }, (token) => {
      if (chrome.runtime.lastError) reject(chrome.runtime.lastError);
      else resolve(token);
    });
  });

  const response = await fetch(endpoint, {
    headers: { Authorization: `Bearer ${token}` },
  });

  if (response.status === 401) {
    // Token may be stale — remove it and let the user re-authenticate
    await new Promise(resolve => chrome.identity.removeCachedAuthToken({ token }, resolve));
    throw new Error("Authentication required");
  }

  return response.json();
}

8. Chrome Web Store Security Review Requirements

The Chrome Web Store automated and manual review process flags several patterns that will get your extension rejected or removed:

ViolationWhat triggers it
Remotely hosted codeLoading JS from external URLs at runtime
Excessive permissionsRequesting permissions not used in the declared functionality
Obfuscated codeMinification is fine; deliberate obfuscation is not
Data collection without disclosureSending user data to external servers without a privacy policy
Broad host permissions without justification<all_urls> when specific hosts would suffice

Practical steps to pass review cleanly:

  1. Declare only permissions you use. Run a permission audit before each release. Tools like chrome-extension-cli can help surface unused permissions.
  2. Bundle all code. Every script must ship inside the extension package. No dynamic import() from external URLs.
  3. Provide a privacy policy. Required for any extension that handles personal data. Link it in the store listing and in your manifest.json metadata.
  4. Use the minimum host permission scope. Prefer activeTab over <all_urls>. If you need broad access, write a clear justification in the store submission form.
  5. Avoid eval and Function() constructor. Both are blocked by MV3 CSP and will fail automated scanning.

9. Security Audit Checklist

Run through this list before every release:

Manifest and permissions

  • All requested permissions are actively used
  • Host permissions are scoped as narrowly as possible
  • content_security_policy is explicitly defined and does not include unsafe-inline or unsafe-eval
  • No external scripts listed in web_accessible_resources unnecessarily

Message passing

  • Every onMessage listener validates sender.id
  • Message payloads are validated against an expected schema before acting on them
  • onMessageExternal uses an explicit origin allowlist

Storage

  • Sensitive values (tokens, credentials, PII) are encrypted before storage
  • Session-scoped data uses chrome.storage.session not chrome.storage.local
  • No sensitive data written to console or sent to error tracking without sanitization

DOM and rendering

  • No direct innerHTML interpolation of external or user-supplied data
  • A DOM sanitizer (DOMPurify) is used wherever rich HTML rendering is required
  • Content scripts do not inject arbitrary HTML from page context

Network

  • All API endpoints use HTTPS
  • SRI hashes are set for any CDN-loaded static assets
  • OAuth tokens are sent in headers, not query strings, and removed on sign-out

Build pipeline

  • Dependency lockfile is committed and verified in CI
  • No known-vulnerable packages (run npm audit or equivalent)
  • Source maps are not included in the production extension package

Store submission

  • Privacy policy URL is live and accurate
  • Store listing description matches actual functionality
  • All required permissions have a user-facing justification ready

Closing Thoughts

Security is not a single feature you ship — it is a set of habits applied consistently across every release. The threat model for Chrome extensions is real: compromised extensions have been used to steal banking credentials, hijack search results, and exfiltrate session cookies at scale.

The good news is that Manifest V3 has already removed the most dangerous primitives. What remains is for you to lock down your CSP, validate every message crossing a trust boundary, encrypt data that deserves protection, and keep your dependency surface small and audited.

Start with the checklist above and build it into your CI pipeline as a gate, not an afterthought. Your users are trusting your extension with access to their browser — that trust is worth protecting.

Share this article

Build better extensions with free tools

Icon generator, MV3 converter, review exporter, and more — no signup needed.

Related Articles