Chrome Extension Security Hardening — Developer Guide
32 min readChrome Extension Security Hardening
An advanced, actionable guide to hardening Chrome extensions against real-world attack vectors. This goes beyond the basics covered in security-best-practices.md with concrete implementations you can drop into your codebase.
Content Security Policy Configuration
MV3 provides a default CSP, but you should tighten it further for different extension contexts.
Extension Pages CSP
Lock down your popup, options page, and side panel:
{
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'none'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self' https://api.yourservice.com; frame-ancestors 'none'; base-uri 'self'"
}
}
Key directives:
object-src 'none'— blocks Flash, Java, and other plugin-based contentframe-ancestors 'none'— prevents your extension pages from being embedded in iframes (clickjacking defense)base-uri 'self'— prevents<base>tag injection that redirects relative URLsconnect-src— whitelist only the exact API origins your extension contacts
Sandbox CSP
If you need to run untrusted HTML (e.g., rendering user-provided templates), use a sandboxed page:
{
"sandbox": {
"pages": ["sandbox.html"]
},
"content_security_policy": {
"sandbox": "sandbox allow-scripts; script-src 'self'; object-src 'none'"
}
}
The sandboxed page runs in a unique origin with no access to extension APIs. Communicate with it via postMessage:
// background.ts — send data to sandbox for processing
const iframe = document.createElement('iframe');
iframe.src = chrome.runtime.getURL('sandbox.html');
iframe.addEventListener('load', () => {
iframe.contentWindow?.postMessage(
{ type: 'RENDER_TEMPLATE', template: userTemplate, data: safeData },
'*' // sandbox has unique origin, so '*' is acceptable here
);
});
// sandbox.html script — receives and processes
window.addEventListener('message', (event) => {
if (event.data.type === 'RENDER_TEMPLATE') {
const result = renderTemplate(event.data.template, event.data.data);
event.source?.postMessage({ type: 'RENDER_RESULT', html: result }, event.origin);
}
});
Input Sanitization in Content Scripts
Content scripts run in the context of web pages. Any data read from the DOM is attacker-controlled.
Never Use innerHTML with Page Data
// DANGEROUS — XSS via DOM data
const title = document.querySelector('h1')?.textContent;
container.innerHTML = `<div class="overlay">${title}</div>`;
// SAFE — use DOM APIs
const title = document.querySelector('h1')?.textContent ?? '';
const div = document.createElement('div');
div.className = 'overlay';
div.textContent = title; // textContent auto-escapes
container.appendChild(div);
Build a Sanitization Utility
For cases where you must work with HTML, build a strict sanitizer:
const ALLOWED_TAGS = new Set(['b', 'i', 'em', 'strong', 'a', 'br', 'p', 'span']);
const ALLOWED_ATTRS: Record<string, Set<string>> = {
a: new Set(['href', 'title']),
span: new Set(['class']),
};
function sanitizeHTML(dirty: string): string {
const template = document.createElement('template');
template.innerHTML = dirty;
const walker = document.createTreeWalker(
template.content,
NodeFilter.SHOW_ELEMENT,
{
acceptNode(node) {
const el = node as Element;
if (!ALLOWED_TAGS.has(el.tagName.toLowerCase())) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
}
}
);
const nodesToRemove: Element[] = [];
// Collect disallowed nodes
const allElements = template.content.querySelectorAll('*');
for (const el of allElements) {
const tag = el.tagName.toLowerCase();
if (!ALLOWED_TAGS.has(tag)) {
nodesToRemove.push(el);
continue;
}
// Strip disallowed attributes
const allowedAttrs = ALLOWED_ATTRS[tag] ?? new Set();
for (const attr of Array.from(el.attributes)) {
if (!allowedAttrs.has(attr.name)) {
el.removeAttribute(attr.name);
}
}
// Validate href values — block javascript: URLs
if (el.hasAttribute('href')) {
const href = el.getAttribute('href') ?? '';
if (!/^https?:\/\//i.test(href) && !href.startsWith('#') && !href.startsWith('/')) {
el.removeAttribute('href');
}
}
}
for (const node of nodesToRemove) {
node.replaceWith(...Array.from(node.childNodes));
}
return template.innerHTML;
}
Validate URLs Before Navigation
function isSafeURL(url: string): boolean {
try {
const parsed = new URL(url);
return ['https:', 'http:'].includes(parsed.protocol);
} catch {
return false;
}
}
// Use before chrome.tabs.create, window.open, etc.
function safeNavigate(url: string): void {
if (!isSafeURL(url)) {
console.error('Blocked navigation to unsafe URL:', url);
return;
}
chrome.tabs.create({ url });
}
Secure Message Validation
Verify Sender in onMessage
Every message handler must validate who sent the message:
// background.ts
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
// Verify the sender is our own extension
if (sender.id !== chrome.runtime.id) {
console.warn('Message from unknown extension:', sender.id);
return false;
}
// For content script messages, 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 shape with a type guard
if (!isValidMessage(message)) {
console.warn('Malformed message:', message);
return false;
}
handleMessage(message, sender, sendResponse);
return true; // keep channel open for async response
});
// Type guard for message validation
interface ExtensionMessage {
type: string;
payload: unknown;
nonce?: string;
}
function isValidMessage(msg: unknown): msg is ExtensionMessage {
if (typeof msg !== 'object' || msg === null) return false;
const obj = msg as Record<string, unknown>;
return typeof obj.type === 'string' && obj.type.length > 0 && obj.type.length < 100;
}
Secure External Messaging
If your extension accepts messages from web pages via externally_connectable:
{
"externally_connectable": {
"matches": ["https://app.yourservice.com/*"]
}
}
// background.ts — handle external messages
chrome.runtime.onMessageExternal.addListener((message, sender, sendResponse) => {
// sender.url is the page URL, sender.id is undefined for web pages
if (!sender.url) {
return false;
}
const senderOrigin = new URL(sender.url).origin;
const trustedOrigins = new Set(['https://app.yourservice.com']);
if (!trustedOrigins.has(senderOrigin)) {
console.warn('External message from untrusted origin:', senderOrigin);
return false;
}
// Rate limit external messages
const key = `rate:${senderOrigin}`;
const now = Date.now();
const windowMs = 60_000;
const maxRequests = 30;
chrome.storage.session.get(key, (data) => {
const timestamps: number[] = (data[key] ?? []).filter(
(t: number) => now - t < windowMs
);
if (timestamps.length >= maxRequests) {
sendResponse({ error: 'RATE_LIMITED' });
return;
}
timestamps.push(now);
chrome.storage.session.set({ [key]: timestamps });
handleExternalMessage(message, sender, sendResponse);
});
return true;
});
Minimal Permissions with Optional Permissions
Audit Your Permissions
Map every permission to the feature that requires it:
// permissions-map.ts — document why each permission exists
export const PERMISSION_JUSTIFICATION = {
required: {
storage: 'Core settings and user preferences',
activeTab: 'Read page content when user clicks the extension icon',
},
optional: {
bookmarks: 'Bookmark import/export feature (Settings > Import)',
history: 'History search feature (activated from command palette)',
downloads: 'Export data as file (activated from export dialog)',
tabs: 'Tab overview panel (activated from side panel)',
},
} as const;
Request Permissions Just-In-Time
async function withPermission<T>(
permission: chrome.permissions.Permissions,
action: () => Promise<T>,
fallback?: () => T
): Promise<T> {
const hasPermission = await chrome.permissions.contains(permission);
if (hasPermission) {
return action();
}
const granted = await chrome.permissions.request(permission);
if (granted) {
return action();
}
if (fallback) {
return fallback();
}
throw new Error(`Permission denied: ${JSON.stringify(permission)}`);
}
// Usage
async function exportBookmarks() {
return withPermission(
{ permissions: ['bookmarks'] },
async () => {
const tree = await chrome.bookmarks.getTree();
return serializeBookmarks(tree);
},
() => {
showNotification('Bookmark access is required for export.');
return null;
}
);
}
Release Permissions When No Longer Needed
async function cleanupUnusedPermissions(): Promise<void> {
const settings = await chrome.storage.local.get('enabledFeatures');
const enabled = new Set(settings.enabledFeatures ?? []);
const featurePermissions: Record<string, string[]> = {
bookmarkSync: ['bookmarks'],
historySearch: ['history'],
downloadExport: ['downloads'],
};
for (const [feature, perms] of Object.entries(featurePermissions)) {
if (!enabled.has(feature)) {
const removed = await chrome.permissions.remove({ permissions: perms });
if (removed) {
console.log(`Released permissions for disabled feature: ${feature}`);
}
}
}
}
// Run on extension startup
chrome.runtime.onStartup.addListener(cleanupUnusedPermissions);
Storage Encryption for Sensitive Data
Chrome extension storage is not encrypted at rest. Any extension with the storage permission can read its own storage, and local storage is accessible via the file system.
Encrypt Sensitive Values
class SecureStorage {
private keyMaterial: CryptoKey | null = null;
/**
* Derive an encryption key from a user-provided passphrase.
* For extensions that authenticate users, derive from the auth token instead.
*/
async init(passphrase: string): Promise<void> {
const encoder = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey(
'raw',
encoder.encode(passphrase),
'PBKDF2',
false,
['deriveKey']
);
// Use a fixed salt stored in extension storage (generated once)
let { encSalt } = await chrome.storage.local.get('encSalt');
if (!encSalt) {
encSalt = Array.from(crypto.getRandomValues(new Uint8Array(16)));
await chrome.storage.local.set({ encSalt });
}
this.keyMaterial = await crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: new Uint8Array(encSalt),
iterations: 310_000, // OWASP recommendation for PBKDF2-SHA256
hash: 'SHA-256',
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
}
async encrypt(plaintext: string): Promise<{ iv: number[]; data: number[] }> {
if (!this.keyMaterial) throw new Error('SecureStorage not initialized');
const iv = crypto.getRandomValues(new Uint8Array(12));
const encoded = new TextEncoder().encode(plaintext);
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
this.keyMaterial,
encoded
);
return {
iv: Array.from(iv),
data: Array.from(new Uint8Array(ciphertext)),
};
}
async decrypt(encrypted: { iv: number[]; data: number[] }): Promise<string> {
if (!this.keyMaterial) throw new Error('SecureStorage not initialized');
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: new Uint8Array(encrypted.iv) },
this.keyMaterial,
new Uint8Array(encrypted.data)
);
return new TextDecoder().decode(decrypted);
}
async setSecure(key: string, value: string): Promise<void> {
const encrypted = await this.encrypt(value);
await chrome.storage.local.set({ [`sec:${key}`]: encrypted });
}
async getSecure(key: string): Promise<string | null> {
const result = await chrome.storage.local.get(`sec:${key}`);
const encrypted = result[`sec:${key}`];
if (!encrypted) return null;
return this.decrypt(encrypted);
}
}
// Usage
const secureStorage = new SecureStorage();
await secureStorage.init(userPassphrase);
await secureStorage.setSecure('apiToken', 'sk-live-abc123...');
const token = await secureStorage.getSecure('apiToken');
XSS Prevention in Extension Pages
Use Trusted Types
Enable Trusted Types in your CSP to prevent DOM XSS at the browser level:
{
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'none'; require-trusted-types-for 'script'"
}
}
Create a Trusted Types policy for controlled DOM manipulation:
// trusted-types.ts
const policy = trustedTypes.createPolicy('extension-safe', {
createHTML(input: string): string {
// Only allow pre-sanitized content
return sanitizeHTML(input);
},
createScriptURL(input: string): string {
// Only allow extension URLs
if (input.startsWith(chrome.runtime.getURL(''))) {
return input;
}
throw new Error(`Blocked script URL: ${input}`);
},
});
export { policy };
Template Rendering Without innerHTML
Build your UI safely with a builder pattern:
type Attrs = Record<string, string>;
function el(tag: string, attrs: Attrs = {}, children: (Node | string)[] = []): HTMLElement {
const element = document.createElement(tag);
for (const [key, value] of Object.entries(attrs)) {
if (key === 'class') {
element.className = value;
} else if (key.startsWith('data-')) {
element.dataset[key.slice(5)] = value;
} else {
element.setAttribute(key, value);
}
}
for (const child of children) {
if (typeof child === 'string') {
element.appendChild(document.createTextNode(child));
} else {
element.appendChild(child);
}
}
return element;
}
// Usage — builds DOM without any innerHTML
function renderBookmarkList(bookmarks: { title: string; url: string }[]): HTMLElement {
return el('ul', { class: 'bookmark-list' },
bookmarks.map((bm) =>
el('li', { class: 'bookmark-item' }, [
el('a', { href: bm.url, class: 'bookmark-link' }, [bm.title]),
])
)
);
}
Clickjacking Protection
Extension pages can be embedded in iframes on malicious sites if you are not careful.
Frame-Busting in Extension Pages
// Run at the top of every extension page (popup, options, side panel)
function preventFraming(): void {
if (window.top !== window.self) {
// We are in an iframe — break out or blank the page
document.documentElement.innerHTML = '';
console.error('Extension page loaded in iframe — possible clickjacking attempt');
// Attempt to redirect the top frame
try {
window.top!.location = window.self.location;
} catch {
// Cross-origin — cannot redirect, page is already blanked
}
return;
}
}
preventFraming();
X-Frame-Options via Web Request Rules
Use Declarative Net Request to add headers to your extension page responses:
{
"declarative_net_request": {
"rule_resources": [{
"id": "security_headers",
"enabled": true,
"path": "rules/security-headers.json"
}]
}
}
[
{
"id": 1,
"priority": 1,
"action": {
"type": "modifyHeaders",
"responseHeaders": [
{
"header": "X-Frame-Options",
"operation": "set",
"value": "DENY"
},
{
"header": "X-Content-Type-Options",
"operation": "set",
"value": "nosniff"
}
]
},
"condition": {
"urlFilter": "*",
"resourceTypes": ["sub_frame"]
}
}
]
Network Request Security
HTTPS Enforcement
Block all non-HTTPS requests from your extension:
// network-security.ts
const ALLOWED_PROTOCOLS = new Set(['https:']);
function enforceHTTPS(url: string): string {
const parsed = new URL(url);
if (!ALLOWED_PROTOCOLS.has(parsed.protocol)) {
throw new Error(`Blocked insecure request to ${parsed.protocol}//${parsed.host}`);
}
return url;
}
// Wrap fetch for the entire extension
const originalFetch = globalThis.fetch;
globalThis.fetch = async function secureFetch(
input: RequestInfo | URL,
init?: RequestInit
): Promise<Response> {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
enforceHTTPS(url);
return originalFetch.call(globalThis, input, init);
};
API Client with Security Defaults
class SecureAPIClient {
private baseURL: string;
private timeout: number;
constructor(baseURL: string, timeout = 10_000) {
if (!baseURL.startsWith('https://')) {
throw new Error('SecureAPIClient requires HTTPS');
}
this.baseURL = baseURL.replace(/\/$/, '');
this.timeout = timeout;
}
async request<T>(path: string, options: RequestInit = {}): Promise<T> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
const response = await fetch(`${this.baseURL}${path}`, {
...options,
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
...options.headers,
},
// Prevent credentials from leaking
credentials: 'omit',
// Prevent referrer leakage
referrerPolicy: 'no-referrer',
});
if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`);
}
// Validate Content-Type before parsing
const contentType = response.headers.get('content-type') ?? '';
if (!contentType.includes('application/json')) {
throw new Error(`Unexpected Content-Type: ${contentType}`);
}
return response.json() as Promise<T>;
} finally {
clearTimeout(timeoutId);
}
}
}
// Usage
const api = new SecureAPIClient('https://api.yourservice.com');
const data = await api.request<{ items: string[] }>('/v1/items');
Third-Party Dependency Auditing
Lock and Audit Dependencies
# Use a lockfile — always
npm ci # not npm install in CI
# Audit for known vulnerabilities
npm audit --production
# Check for unused dependencies
npx depcheck
# Check for outdated packages
npm outdated
Vendoring Critical Dependencies
For maximum security, vendor your critical dependencies so supply chain attacks cannot affect your published extension:
# Create a vendor directory
mkdir -p src/vendor
# Copy the specific files you need (not entire packages)
cp node_modules/some-lib/dist/index.min.js src/vendor/some-lib.js
# Generate integrity hashes
shasum -a 256 src/vendor/*.js > src/vendor/CHECKSUMS.sha256
Add a verification step to your build:
// scripts/verify-vendor.ts
import { readFileSync } from 'fs';
import { createHash } from 'crypto';
const checksums = readFileSync('src/vendor/CHECKSUMS.sha256', '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(file);
const actual = createHash('sha256').update(content).digest('hex');
if (actual !== hash) {
console.error(`INTEGRITY FAILURE: ${file}`);
console.error(` Expected: ${hash}`);
console.error(` Actual: ${actual}`);
process.exit(1);
}
}
console.log('All vendor integrity checks passed.');
Subresource Integrity for CDN Resources
If you must load resources from a CDN (e.g., in a sandboxed page):
<script
src="https://cdn.example.com/lib.min.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxAh6jE..."
crossorigin="anonymous"
></script>
Chrome Web Store Security Review Preparation
The Chrome Web Store reviews extensions for security issues. Prepare your submission to avoid rejections.
Pre-Submission Checklist
// scripts/security-audit.ts — run before every submission
import { readFileSync } from 'fs';
interface AuditResult {
check: string;
passed: boolean;
details?: string;
}
function auditManifest(): AuditResult[] {
const manifest = JSON.parse(readFileSync('manifest.json', 'utf-8'));
const results: AuditResult[] = [];
// Check for overly broad host permissions
const hostPerms = manifest.host_permissions ?? [];
results.push({
check: 'No wildcard host permissions',
passed: !hostPerms.includes('<all_urls>') && !hostPerms.includes('*://*/*'),
details: hostPerms.join(', '),
});
// Check CSP exists
results.push({
check: 'CSP defined',
passed: !!manifest.content_security_policy?.extension_pages,
});
// Check no remote code
const csp = manifest.content_security_policy?.extension_pages ?? '';
results.push({
check: 'No unsafe-eval in CSP',
passed: !csp.includes('unsafe-eval'),
});
// Check permissions are documented
const allPerms = [...(manifest.permissions ?? []), ...(manifest.optional_permissions ?? [])];
results.push({
check: 'Permissions count reasonable',
passed: allPerms.length <= 10,
details: `${allPerms.length} permissions declared`,
});
return results;
}
const results = auditManifest();
const failures = results.filter((r) => !r.passed);
console.log('\nSecurity Audit Results:');
for (const r of results) {
const icon = r.passed ? 'PASS' : 'FAIL';
console.log(` [${icon}] ${r.check}${r.details ? ` (${r.details})` : ''}`);
}
if (failures.length > 0) {
console.error(`\n${failures.length} check(s) failed. Fix before submission.`);
process.exit(1);
}
Justification Document
Create a PERMISSIONS_JUSTIFICATION.md that maps each permission to its user-facing feature. Chrome Web Store reviewers check this:
| Permission | Feature | User Action |
|---|---|---|
| storage | Save settings | Automatic |
| activeTab | Read current page | User clicks icon |
| bookmarks | Import bookmarks | Settings > Import |
Code Minification Considerations
Chrome Web Store may reject heavily obfuscated code. Use minification but not obfuscation:
// vite.config.ts
export default defineConfig({
build: {
// Minify but keep readable
minify: 'terser',
terserOptions: {
compress: {
drop_console: false, // Keep console for review transparency
},
mangle: {
keep_fnames: true, // Preserve function names for reviewability
},
format: {
comments: 'some', // Keep license comments
},
},
// Include source maps for review
sourcemap: true,
},
});
Summary
Security hardening is not optional for Chrome extensions. Extensions run with elevated privileges and access sensitive user data. Apply these measures as layers of defense:
- CSP — restrict what code can execute in your extension pages
- Input sanitization — never trust data from web pages
- Message validation — verify every sender, validate every payload
- Minimal permissions — request only what you need, when you need it
- Encrypted storage — protect sensitive data at rest
- XSS prevention — use Trusted Types and DOM APIs, avoid innerHTML
- Clickjacking defense — prevent embedding of extension pages
- Network security — enforce HTTPS, set timeouts, validate responses
- Dependency auditing — vendor critical deps, verify integrity
- Pre-submission audit — automated checks before every Web Store upload
Cross-references:
docs/guides/security-best-practices.md— foundational security conceptsdocs/guides/permissions-model.md— Chrome permissions system in depthdocs/guides/content-script-isolation.md— content script security boundariesdocs/guides/web-request-patterns.md— network request handling
Related Articles
Related Articles
Part of the Chrome Extension Guide by theluckystrike. Built at zovo.one.