Chrome Extension Security Hardening — Comprehensive Protection Guide

22 min read

Chrome Extension Security Hardening

Chrome extensions operate with elevated privileges within the browser, giving them access to sensitive APIs, user data, and browser functionality. This makes them attractive targets for attackers. A single vulnerability in an extension can compromise millions of users’ data and expose them to malicious activities. This comprehensive guide covers the essential security hardening measures every extension developer must implement to protect their users from modern attack vectors.

Security is not an afterthought—it must be architected into your extension from day one. The techniques covered here provide defense-in-depth, layering multiple protections so that even if one control fails, others remain in place. Whether you’re building a simple utility extension or a complex enterprise tool, these practices apply to your project.

Content Security Policy: Your First Line of Defense

Content Security Policy (CSP) serves as the foundational security layer for Chrome extensions, defining what resources the browser is allowed to load and execute. A properly configured CSP prevents cross-site scripting attacks, data injection, and unauthorized resource loading. In Manifest V3, you define CSP in your manifest.json file, and understanding how to configure it correctly is essential for every extension developer.

Understanding Default CSP Behavior

Chrome extensions in Manifest V3 come with a default CSP that provides basic protection, but this default is intentionally permissive to accommodate common use cases. The default CSP allows scripts from the extension’s own origin and some external sources, but it does not restrict much beyond basic script execution. Relying on defaults leaves your extension vulnerable to attacks that could be easily prevented with custom CSP rules.

The default policy allows script-src 'self' https://ajax.googleapis.com, which permits loading scripts from Google’s CDN. While convenient during development, this flexibility can become a liability if an attacker manages to inject malicious code into a third-party script or if a CDN is compromised. Always audit your CSP and tighten it to match your actual requirements.

Configuring Strict Extension Page CSP

Your popup, options page, side panel, and other extension UI pages should have the strictest possible CSP. These pages run in the extension’s context and often display sensitive information or handle user credentials. A breach in these pages can expose all extension functionality to attackers.

{
  "content_security_policy": {
    "extension_pages": "script-src 'self'; object-src 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' https://api.yourservice.com; frame-ancestors 'none'; base-uri 'self'"
  }
}

This configuration locks down your extension pages significantly. The script-src 'self' directive ensures only your own extension’s JavaScript can execute, blocking any attempt to load malicious external scripts. The object-src 'none' directive is particularly important—it prevents Flash, Java applets, and other legacy plugin content from loading, eliminating an entire class of vulnerabilities. The frame-ancestors 'none' directive prevents your extension pages from being embedded in iframes on malicious websites, defending against clickjacking attacks. The base-uri 'self' directive blocks attempts to override base URLs, which attackers could use to redirect relative links to malicious destinations.

Sandbox Page CSP for Untrusted Content

Sometimes extensions need to render content that cannot be fully trusted—for example, user-generated HTML templates, Markdown rendering, or content from external sources. Running this content in your main extension context exposes your entire extension to potential compromise. Chrome’s sandboxed pages provide a solution by running content in an isolated environment with no access to extension APIs.

{
  "sandbox": {
    "pages": ["sandbox.html", "renderer.html"]
  },
  "content_security_policy": {
    "sandbox": "sandbox allow-scripts; script-src 'self'; object-src 'none'; style-src 'self' 'unsafe-inline'"
  }
}

Sandboxed pages run in a unique origin separate from your extension, meaning they cannot access chrome.* APIs directly. Any XSS vulnerabilities in sandboxed content remain contained and cannot escalate to compromise the extension or user data. Communication with sandboxed pages occurs through the postMessage API, which requires careful validation to prevent message injection attacks.

Cross-Site Scripting Prevention

Cross-site scripting (XSS) remains one of the most common and dangerous vulnerabilities in web applications, and extensions are not immune. Extensions face unique XSS challenges because content scripts run in the context of web pages, meaning any data from the page must be treated as potentially malicious. Even in extension-only pages, improper handling of user input or external data can lead to XSS vulnerabilities.

The Danger of innerHTML

The most common XSS mistake in extension development is using innerHTML with data that originates from untrusted sources. When you set innerHTML, the browser parses the string as HTML, executing any embedded scripts. If an attacker controls any part of this string, they can inject malicious JavaScript that runs in your extension’s context.

// DANGEROUS: Never do this with page data
function displayPageTitle() {
  const pageTitle = document.querySelector('h1')?.textContent;
  document.getElementById('output').innerHTML = `<h2>${pageTitle}</h2>`;
}

// SAFE: Use textContent for untrusted data
function displayPageTitle() {
  const pageTitle = document.querySelector('h1')?.textContent ?? '';
  const heading = document.createElement('h2');
  heading.textContent = pageTitle;
  document.getElementById('output').appendChild(heading);
}

The safe version uses textContent instead of innerHTML. The textContent setter automatically escapes any HTML special characters, treating the input as literal text rather than executable markup. This simple change prevents the majority of XSS attacks in content scripts.

Building a Sanitization Library

Sometimes you genuinely need to render HTML—for example, when displaying formatted user content or rendering Markdown. In these cases, you must sanitize the HTML to remove any potentially dangerous elements and attributes while preserving safe formatting.

const ALLOWED_TAGS = new Set([
  'b', 'i', 'em', 'strong', 'u', 'a', 'p', 'br', 'ul', 'ol', 'li',
  'blockquote', 'code', 'pre', 'span', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'
]);

const ALLOWED_ATTRS: Record<string, Set<string>> = {
  a: new Set(['href', 'title', 'target']),
  span: new Set(['class']),
  div: new Set(['class']),
};

function sanitizeHTML(input: string): string {
  const template = document.createElement('template');
  template.innerHTML = input;
  
  const elements = template.content.querySelectorAll('*');
  
  for (const el of elements) {
    const tag = el.tagName.toLowerCase();
    
    // Remove disallowed tags entirely
    if (!ALLOWED_TAGS.has(tag)) {
      el.remove();
      continue;
    }
    
    // Strip disallowed attributes
    const allowed = ALLOWED_ATTRS[tag] ?? new Set();
    const attrs = Array.from(el.attributes);
    
    for (const attr of attrs) {
      if (!allowed.has(attr.name)) {
        el.removeAttribute(attr.name);
      }
    }
    
    // Validate URLs in href attributes
    if (el.hasAttribute('href')) {
      const href = el.getAttribute('href') ?? '';
      if (!/^https?:\/\//i.test(href) && !href.startsWith('#') && !href.startsWith('/')) {
        el.removeAttribute('href');
      }
    }
  }
  
  return template.innerHTML;
}

This sanitizer provides a whitelist approach—only explicitly allowed tags and attributes can pass through. Any script tags, event handlers, or dangerous attributes are stripped before the content is rendered. Whitelisting is inherently safer than blacklisting because new attack techniques won’t bypass your defenses unless they use allowed tags.

Trusted Types for Extension Pages

Modern browsers support Trusted Types, a browser-enforced mechanism that prevents DOM XSS at the API level. When enabled, any code that assigns to dangerous sinks like innerHTML, insertAdjacentHTML, or document.write throws an error unless you use a Trusted Type policy.

{
  "content_security_policy": {
    "extension_pages": "script-src 'self'; object-src 'none'; require-trusted-types-for 'script'"
  }
}
// Define a Trusted Types policy
const safePolicy = trustedTypes.createPolicy('extension-safe', {
  createHTML(input: string): string {
    return sanitizeHTML(input);
  },
  createScriptURL(input: string): string {
    // Only allow extension-relative URLs
    if (input.startsWith('/') || input.startsWith(chrome.runtime.getURL(''))) {
      return input;
    }
    throw new Error('Blocked external script URL');
  }
});

// Usage - this will work
const sanitized = safePolicy.createHTML(userInput);
element.innerHTML = sanitized;

// This would throw an error without a policy
// element.innerHTML = userInput; // TypeError!

Trusted Types shift the security model from “sanitize everything” to “explicitly allow what you need.” Any code path that assigns HTML to the DOM must go through your policy, making it impossible to accidentally use unsanitized data.

Secure Messaging Between Contexts

Chrome extensions consist of multiple execution contexts—background scripts, popup pages, options pages, content scripts, and sometimes sandboxed pages or service workers. These contexts communicate through the message passing API, and each message represents a potential attack vector if not properly validated.

Validating Message Senders

Every message handler must verify the identity of the sender before processing the message. Without validation, malicious websites can send messages to your content scripts, and compromised content scripts can send messages to your background script.

chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  // Verify the sender is our extension
  if (sender.id !== chrome.runtime.id) {
    console.warn('Rejected message from unknown extension:', sender.id);
    return false;
  }
  
  // For content scripts, verify the tab URL
  if (sender.tab) {
    const url = new URL(sender.tab.url ?? '');
    const allowedOrigins = [
      'https://app.yourservice.com',
      'https://dashboard.yourservice.com'
    ];
    
    if (!allowedOrigins.includes(url.origin)) {
      console.warn('Message from untrusted origin:', url.origin);
      return false;
    }
  }
  
  // Validate message structure
  if (!isValidMessageFormat(message)) {
    console.warn('Invalid message format:', message);
    return false;
  }
  
  processMessage(message, sender, sendResponse);
  return true; // Keep message channel open for async response
});

interface MessagePayload {
  type: string;
  action: string;
  data?: unknown;
}

function isValidMessageFormat(msg: unknown): msg is MessagePayload {
  if (typeof msg !== 'object' || msg === null) return false;
  const obj = msg as Record<string, unknown>;
  return (
    typeof obj.type === 'string' &&
    typeof obj.action === 'string' &&
    obj.type.length < 50 &&
    obj.action.length < 50
  );
}

This handler implements multiple validation layers. First, it confirms the message comes from your extension by checking sender.id. Second, for messages from content scripts, it validates that the originating page is on your allowlist. Third, it validates the message structure to prevent malformed or unexpected messages from triggering code paths that assume valid input.

Rate Limiting External Messages

If your extension uses externally_connectable to accept messages from specific websites, implement rate limiting to prevent abuse:

chrome.runtime.onMessageExternal.addListener((message, sender, sendResponse) => {
  if (!sender.url) return false;
  
  const origin = new URL(sender.url).origin;
  const rateLimitKey = `ratelimit:${origin}`;
  const now = Date.now();
  const WINDOW_MS = 60_000;
  const MAX_REQUESTS = 30;
  
  chrome.storage.session.get(rateLimitKey, (data) => {
    const timestamps: number[] = (data[rateLimitKey] ?? [])
      .filter((t: number) => now - t < WINDOW_MS);
    
    if (timestamps.length >= MAX_REQUESTS) {
      sendResponse({ error: 'RATE_LIMITED' });
      return;
    }
    
    timestamps.push(now);
    chrome.storage.session.set({ [rateLimitKey]: timestamps });
    
    handleExternalMessage(message, sender, sendResponse);
  });
  
  return true;
});

Rate limiting prevents denial-of-service attacks where a compromised website floods your extension with messages to exhaust resources or trigger rate limits on your backend APIs.

Permission Minimization

Every permission you request increases your extension’s attack surface and the potential impact of a compromise. The principle of least privilege dictates that you should request only the permissions absolutely necessary for your core functionality, and request them only when needed.

Using Optional Permissions

Optional permissions allow users to grant access to sensitive APIs only when they need specific features, rather than requiring all permissions at installation. This improves both security and user trust—users are more likely to install extensions that request fewer permissions upfront.

async function enableFeatureWithPermission(
  featureName: string,
  requiredPermissions: string[],
  featureAction: () => Promise<void>
): Promise<void> {
  // Check if we already have the permission
  const hasPermission = await chrome.permissions.contains({
    permissions: requiredPermissions
  });
  
  if (hasPermission) {
    return featureAction();
  }
  
  // Request the permission
  const granted = await chrome.permissions.request({
    permissions: requiredPermissions
  });
  
  if (granted) {
    return featureAction();
  }
  
  // Permission denied - show user-friendly message
  showFeatureDisabledMessage(featureName, requiredPermissions);
}

// Example: Request bookmarks permission only when exporting
async function exportBookmarks() {
  return enableFeatureWithPermission(
    'Bookmark Export',
    ['bookmarks'],
    async () => {
      const tree = await chrome.bookmarks.getTree();
      const exportData = serializeBookmarks(tree);
      downloadAsFile(exportData, 'bookmarks.json');
    }
  );
}

This pattern ensures that dangerous permissions like bookmarks, history, or downloads are only requested when the user actively tries to use a feature that needs them. Users understand why you’re asking when the request is contextual.

Documenting Permission Justification

Chrome Web Store reviewers increasingly scrutinize extensions for unnecessary permissions. Document why each permission exists and which user-facing feature requires it. This documentation helps reviewers understand your design decisions and speeds up the approval process.

Create a PERMISSIONS_JUSTIFICATION.md file in your repository:

| Permission | Justification | User Trigger |
|------------|---------------|--------------|
| storage | Save user preferences and extension state | Automatic |
| activeTab | Read page content when user clicks extension icon | Click on extension icon |
| bookmarks | Import/export bookmarks feature | Settings > Import/Export |
| notifications | Alert users when background tasks complete | Background processing |
| tabs | Create tab management features | Side panel tab overview |

This table maps each permission to its purpose and how the user activates the feature. Permissions that users trigger themselves are easier to justify than permissions that run automatically.

Code Signing and Integrity

Code signing provides cryptographic verification that your extension code has not been tampered with since you signed it. While Chrome’s update mechanism handles most integrity concerns, implementing additional verification protects against local tampering and provides assurance to security-conscious users.

Ensuring Update Integrity

Chrome’s update infrastructure uses CRX files with embedded signatures. The Chrome Web Store signs extensions automatically, and Chrome verifies these signatures on every update. However, extensions loaded in developer mode or as unpacked extensions skip some verification. For enterprise deployments or extensions distributed outside the Web Store, additional integrity measures may be necessary.

The most important step is ensuring your build process produces reproducible outputs. Use locked dependency versions, record build environment details, and publish checksums of your releases. Users can then verify they received the exact code you published.

Preventing Extension Tampering

While Chrome’s security model protects against most tampering, adding runtime integrity checks provides defense-in-depth:

// Verify extension integrity at startup
async function verifyIntegrity(): Promise<boolean> {
  const manifest = chrome.runtime.getManifest();
  
  // Verify expected permissions
  const requiredPermissions = ['storage', 'activeTab'];
  const hasPermissions = requiredPermissions.every(
    p => manifest.permissions?.includes(p)
  );
  
  if (!hasPermissions) {
    console.error('Permission mismatch - possible tampering');
    return false;
  }
  
  // Verify extension ID matches expected value
  const expectedId = 'your-extension-id-here';
  if (chrome.runtime.id !== expectedId) {
    console.error('Extension ID mismatch - possible loading of unauthorized version');
    return false;
  }
  
  return true;
}

// Run integrity check on startup
chrome.runtime.onStartup.addListener(async () => {
  const valid = await verifyIntegrity();
  if (!valid) {
    // Disable extension functionality or notify user
    console.error('Integrity check failed - extension may be compromised');
  }
});

These checks won’t prevent a determined attacker with full control of the browser, but they do catch accidental modifications and make tampering more difficult.

Supply Chain Security

Modern software relies on extensive dependency chains, and each dependency represents a potential attack vector. Supply chain attacks have compromised major projects, and extension developers must take proactive steps to protect their users from malicious or compromised dependencies.

Dependency Auditing

Regularly audit your dependencies for known vulnerabilities and unusual behavior:

# Install only from lockfiles - never from package.json directly
npm ci

# Check for known vulnerabilities
npm audit --production

# Check for out-of-date packages with security updates
npm outdated

# Review dependency tree for unexpected packages
npm ls --depth=5

# Use npm fund to see who maintains packages
npm fund

Schedule these checks in your CI pipeline and fail builds when critical vulnerabilities are found. Automated scanning catches issues before they reach production.

Vendoring Critical Dependencies

For maximum security, consider vendoring dependencies you consider critical—copying the specific files into your repository rather than fetching them from npm at build time. This provides protection against supply chain attacks where an attacker publishes a malicious update to a popular package.

# Copy specific versions of critical packages
cp node_modules/lodash/dist/lodash.min.js src/vendor/lodash.js
cp node_modules/dompurify/dist/purify.min.js src/vendor/dompurify.js

# Generate checksums for verification
sha256sum src/vendor/*.js > src/vendor/CHECKSUMS.txt
// Verify vendor files before use
import { readFileSync } from 'fs';
import { createHash } from 'crypto';

function verifyVendorIntegrity(): void {
  const checksums = readFileSync('src/vendor/CHECKSUMS.txt', 'utf-8')
    .trim()
    .split('\n')
    .map(line => {
      const [hash, file] = line.split(/\s+/);
      return { hash, file };
    });
  
  for (const { hash, file } of checksums) {
    const content = readFileSync(`src/vendor/${file}`);
    const actual = createHash('sha256').update(content).digest('hex');
    
    if (actual !== hash) {
      throw new Error(`Integrity check failed for ${file}`);
    }
  }
  
  console.log('Vendor integrity verified');
}

Vendoring requires more maintenance—you’re responsible for updating vendored code when security patches are released—but it eliminates an entire class of supply chain attacks.

Subresource Integrity for External Resources

If you must load resources from external CDNs, use Subresource Integrity (SRI) to verify the content hasn’t been modified:

<script
  src="https://cdn.example.com/library.min.js"
  integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxAhInE+Nk2tk8c6N6N6J6N6J6N6J6N"
  crossorigin="anonymous"
></script>

The integrity attribute contains a hash of the expected content. The browser refuses to execute the script if the actual content doesn’t match. This protects you if a CDN is compromised—the attacker cannot serve modified code without breaking the integrity check.

Security Hardening Checklist

Before publishing your extension, verify these security measures are in place:

Security is layered—each measure reinforces the others. Refer to these related guides for deeper dives into specific topics:


Part of the Chrome Extension Guide by theluckystrike. Built at zovo.one.

No previous article
No next article