Chrome Extension Security Hardening Guide — Protect Your Users

19 min read

Chrome Extension Security Hardening Guide — Protect Your Users

Introduction

Security is not an afterthought—it’s a fundamental aspect of building Chrome extensions that users can trust. With millions of extensions competing in the Chrome Web Store, users place significant trust in the code you publish. A single vulnerability can compromise not only your users’ data but also damage your reputation permanently. Chrome extensions operate with elevated privileges, accessing sensitive APIs and potentially sensitive user data, making security hardening essential.

This comprehensive guide covers the critical security practices every extension developer must implement. From Content Security Policy configuration to encrypted storage, from secure message passing to permission minimization, you’ll learn the techniques that separate vulnerable extensions from robust, production-ready software.

Content Security Policy for Extensions

Chrome extensions have a Content Security Policy (CSP) that’s more permissive than regular web pages but still requires careful configuration. The default CSP for extensions restricts several dangerous capabilities, but you must understand how to customize it appropriately for your extension’s needs.

Understanding Default CSP

Modern extensions (Manifest V3) operate with a default CSP that restricts several dangerous capabilities:

default-src 'self'; script-src 'self'; object-src 'self'; style-src 'self' 'unsafe-inline';

This default prevents your extension from loading remote scripts, which is critical for security. However, it also means you must host all your JavaScript locally and avoid inline script execution when possible.

Configuring CSP in Manifest V3

Define your CSP directly in the manifest.json file:

{
  "content_security_policy": {
    "extension_pages": "script-src 'self'; object-src 'self'; style-src 'self' 'unsafe-inline'; connect-src https://api.example.com https://trusted-cdn.com"
  }
}

For extensions that need to make API calls, add appropriate connect-src directives. Never use 'unsafe-eval' unless absolutely necessary—it allows eval() and similar functions that dramatically increase your attack surface.

CSP for Different Contexts

Different extension contexts may require different CSP configurations. The background service worker, popup, options page, and content scripts each have their own execution environment:

{
  "content_security_policy": {
    "extension_pages": "script-src 'self'; object-src 'self'; style-src 'self'",
    "content_scripts": "script-src 'self'; object-src 'self'; style-src 'self'"
  }
}

When your content scripts need to interact with page scripts, consider using a custom CSP for specific match patterns, but be extremely cautious about allowing page-level script execution.

XSS Prevention in Extension Contexts

Cross-Site Scripting (XSS) in extensions is particularly dangerous because extension scripts run with elevated privileges. An XSS vulnerability in your extension can lead to complete compromise of the user’s browsing experience and potentially access to sensitive Chrome APIs.

Dangerous APIs to Avoid

Several APIs that are common in web development become extremely dangerous in extension contexts:

// NEVER use these in extension contexts
element.innerHTML = userInput;  // Dangerous!
element.outerHTML = userInput;  // Dangerous!
document.write(userInput);       // Dangerous!
eval(userInput);                // Extremely dangerous!
new Function(userInput);        // Extremely dangerous!

Safe Alternatives

Always use safe alternatives for rendering user-controlled content:

// SAFE: Using textContent instead of innerHTML
element.textContent = userInput;

// SAFE: Creating elements programmatically
const span = document.createElement('span');
span.textContent = userInput;
element.appendChild(span);

// SAFE: Using template literals with textContent
const template = document.createElement('template');
template.textContent = userInput;
element.appendChild(template.content.cloneNode(true));

Sanitizing HTML Content

When you must render HTML content, always sanitize it properly:

import DOMPurify from 'dompurify';

// Configure DOMPurify for extension context
const clean = DOMPurify.sanitize(userInput, {
  ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'ul', 'li'],
  ALLOWED_ATTR: ['href', 'class'],
  ALLOW_DATA_ATTR: false
});

element.innerHTML = clean;

URL Validation

Never pass unvalidated URLs to browser APIs that execute them:

// DANGEROUS: Unsanitized URL
chrome.tabs.create({ url: userInput });  // Could be javascript:...

// SAFE: Validate URL scheme
function isSafeUrl(url) {
  try {
    const parsed = new URL(url);
    return ['https:', 'http:'].includes(parsed.protocol);
  } catch {
    return false;
  }
}

if (isSafeUrl(userInput)) {
  chrome.tabs.create({ url: userInput });
}

Secure Message Passing Between Contexts

Extension contexts communicate through Chrome’s message passing API. Ensuring this communication remains secure is critical—malicious websites can attempt to send messages to your extension.

Validating Message Sources

Always verify the sender of messages, especially in content scripts that receive messages from web pages:

// In content script - ALWAYS validate sender
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  // sender.tab contains the tab that sent the message
  // Check that message originates from expected context
  if (!sender.tab || !sender.tab.id) {
    console.error('Invalid sender:', sender);
    return false;
  }
  
  // Verify the tab is one you expect messages from
  const allowedTabIds = [/* your content script tab IDs */];
  if (!allowedTabIds.includes(sender.tab.id)) {
    console.error('Message from unauthorized tab');
    return false;
  }
  
  // Process validated message
  handleMessage(message);
  return true;
});

Message Schema Validation

Define and validate message schemas to prevent injection attacks:

// Define message schema
const MessageSchema = {
  type: 'object',
  properties: {
    action: { type: 'string', enum: ['fetch', 'save', 'delete'] },
    payload: { type: 'object' },
    requestId: { type: 'string' }
  },
  required: ['action', 'requestId']
};

function validateMessage(message) {
  // Simple validation - consider using ajv for complex schemas
  if (!message || typeof message !== 'object') {
    return false;
  }
  
  if (!['fetch', 'save', 'delete'].includes(message.action)) {
    return false;
  }
  
  return true;
}

chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (!validateMessage(message)) {
    sendResponse({ error: 'Invalid message format' });
    return true;
  }
  
  // Process validated message
  handleMessage(message, sendResponse);
  return true;  // Indicates async response
});

Protecting Background Service Workers

The background service worker is the most sensitive context—never trust incoming messages without validation:

// background.js - Service worker
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  // Validate sender is from your extension
  if (sender.id !== chrome.runtime.id) {
    console.error('Message from unknown extension:', sender.id);
    return false;
  }
  
  // Validate message structure
  if (!message || !message.type) {
    return false;
  }
  
  // Handle known message types only
  switch (message.type) {
    case 'GET_DATA':
      return handleGetData(message);
    case 'SAVE_DATA':
      return handleSaveData(message);
    default:
      console.warn('Unknown message type:', message.type);
      return false;
  }
});

Safe External API Communication

Extensions frequently need to communicate with external APIs. This communication must be secured against interception, injection, and data leakage.

Using fetch with Proper Configuration

Always use fetch with explicit configuration for sensitive communications:

async function secureApiRequest(url, data) {
  const response = await fetch(url, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Requested-With': 'XMLHttpRequest',  // Helps prevent CSRF
      'X-Extension-Version': chrome.runtime.getManifest().version
    },
    body: JSON.stringify(data),
    credentials: 'same-origin',  // Don't send cookies to third parties
    mode: 'cors'
  });
  
  if (!response.ok) {
    throw new Error(`API request failed: ${response.status}`);
  }
  
  return response.json();
}

Token Management

Never store API tokens in plain text or in extension code:

// BAD: Token in source code
const API_KEY = 'sk-1234567890abcdef';  // Never do this!

// GOOD: Use Chrome's storage API with encryption
import { encrypt, decrypt } from './crypto-utils.js';

async function storeToken(token) {
  const encrypted = await encrypt(token);
  await chrome.storage.secure.set({ apiToken: encrypted });
}

async function getToken() {
  const result = await chrome.storage.secure.get('apiToken');
  if (!result.apiToken) return null;
  return await decrypt(result.apiToken);
}

CORS and API Security

Configure manifest.json to declare allowed communication endpoints:

{
  "permissions": [
    "activeTab",
    "storage"
  ],
  "host_permissions": [
    "https://api.trusted-service.com/*"
  ]
}

Only request host permissions for domains you actually need to communicate with, and prefer using the activeTab permission when possible to limit access to the current page only.

Permission Minimization Strategies

Requesting fewer permissions improves security and increases user trust. Users are more likely to install extensions that request minimal permissions, and reduced permissions mean reduced attack surface.

Principle of Least Privilege

Only request permissions that your extension absolutely requires to function:

// BAD: Requesting excessive permissions
{
  "permissions": [
    "tabs",
    "cookies",
    "history",
    "bookmarks",
    "management",
    "debugger",
    "proxy",
    "*://*/*"  // Extremely broad!
  ]
}

// GOOD: Minimal permissions for a simple extension
{
  "permissions": [
    "activeTab",
    "storage"
  ],
  "host_permissions": [
    "https://specific-api.com/"
  ]
}

Using Optional Permissions

Declare optional permissions that your extension can function without:

{
  "optional_permissions": [
    "tabs",
    "bookmarks"
  ],
  "optional_host_permissions": [
    "https://optional-feature.com/*"
  ]
}

Request optional permissions at runtime when needed:

async function requestOptionalPermission(permission) {
  try {
    const result = await chrome.permissions.request({
      permissions: [permission]
    });
    
    if (result) {
      console.log('Permission granted:', permission);
      // Enable feature that requires this permission
    } else {
      console.log('Permission denied:', permission);
      // Gracefully disable feature
    }
  } catch (error) {
    console.error('Error requesting permission:', error);
  }
}

Manifest V3 Host Permission Changes

Manifest V3 changed how host permissions work. Understand these changes:

{
  "permissions": [
    "activeTab",
    "storage",
    "scripting"
  ],
  "host_permissions": [
    "https://api.your-service.com/*"
  ]
}

Code Signing and Update Security

Protecting your extension from tampering and ensuring update integrity is crucial for maintaining user trust.

Chrome Web Store Signing

Chrome automatically signs extensions published through the Web Store. However, you should verify your extension’s signature during development:

# Verify extension signature
openssl dgst -sha256 -verify public_key.pem -signature extension.pem crxfile.crx

Preventing Update Manipulation

Configure update URLs securely and verify update sources:

{
  "update_url": "https://clients2.google.com/service/update2/crx",
  "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA..."
}

For self-hosted extensions, implement additional verification:

// Verify update response integrity
async function verifyUpdateResponse(response, expectedSignature) {
  const body = await response.arrayBuffer();
  const signature = await crypto.subtle.sign(
    'RSASSA-PKCS1-v1_5',
    publicKey,
    body
  );
  
  const signatureMatch = timingSafeEqual(
    new Uint8Array(signature),
    new Uint8Array(expectedSignature)
  );
  
  if (!signatureMatch) {
    throw new Error('Update signature verification failed');
  }
  
  return body;
}

CRX Verification

Always verify the integrity of loaded CRX files in development:

function verifyExtensionSignature(extensionPath) {
  return new Promise((resolve, reject) => {
    chrome.management.getAll(extensions => {
      const ext = extensions.find(e => e.path === extensionPath);
      if (ext && ext.installType === 'development') {
        resolve(true);  // Development extensions may not be signed
      }
      // Production extensions should be properly signed
    });
  });
}

Protecting User Data

User data protection is both an ethical obligation and often a legal requirement. Implement proper encryption and secure storage practices.

Using chrome.storage.secure

The chrome.storage.secure API provides encrypted storage using the operating system’s credential store:

// Store sensitive data securely
async function storeUserCredentials(username, password) {
  const credentials = btoa(`${username}:${password}`);
  await chrome.storage.secure.set({
    userCredentials: credentials
  });
}

// Retrieve credentials
async function getUserCredentials() {
  const result = await chrome.storage.secure.get('userCredentials');
  if (!result.userCredentials) return null;
  
  const decoded = atob(result.userCredentials);
  const [username, password] = decoded.split(':');
  return { username, password };
}

Implementing Custom Encryption

For additional protection, implement custom encryption:

// crypto-utils.js
import { generateKey, encrypt, decrypt } from './encryption.js';

async function initializeEncryption() {
  // Generate a unique key for this installation
  const key = await generateKey();
  
  // Store key securely
  const keyData = await crypto.subtle.exportKey('raw', key);
  await chrome.storage.local.set({
    encryptionKey: Array.from(new Uint8Array(keyData))
  });
  
  return key;
}

export async function encryptData(data) {
  const key = await getEncryptionKey();
  const iv = crypto.getRandomValues(new Uint8Array(12));
  
  const encrypted = await crypto.subtle.encrypt(
    { name: 'AES-GCM', iv },
    key,
    new TextEncoder().encode(JSON.stringify(data))
  );
  
  return {
    iv: Array.from(iv),
    data: Array.from(new Uint8Array(encrypted))
  };
}

export async function decryptData(encryptedPackage) {
  const key = await getEncryptionKey();
  const iv = new Uint8Array(encryptedPackage.iv);
  
  const decrypted = await crypto.subtle.decrypt(
    { name: 'AES-GCM', iv },
    key,
    new Uint8Array(encryptedPackage.data)
  );
  
  return JSON.parse(new TextDecoder().decode(decrypted));
}

Data Minimization and Retention

Collect only what you need and retain it only as long as necessary:

// Data retention policy
const RETENTION_PERIOD_DAYS = 30;

async function cleanOldData() {
  const cutoffDate = Date.now() - (RETENTION_PERIOD_DAYS * 24 * 60 * 60 * 1000);
  
  const result = await chrome.storage.local.get('userData');
  const userData = result.userData || [];
  
  const filteredData = userData.filter(item => item.timestamp > cutoffDate);
  
  await chrome.storage.local.set({ userData: filteredData });
}

Security Audit Checklist

Before publishing your extension, verify all security requirements are met:

Pre-Publish Security Checklist

Runtime Security Checklist

Continuous Security Practices


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

No previous article
No next article