Comprehensive WXT browser extension framework patterns, security hardening rules, and cross-browser configuration
Provides comprehensive patterns for building secure cross-browser extensions using the WXT framework.
npx claudepluginhub arustydev/aiThis skill inherits all available tools. When active, it can use any tool Claude has access to.
Comprehensive guide for building cross-browser extensions with WXT, including security hardening, Firefox/Safari specifics, and production patterns.
WXT is the leading framework for browser extension development, offering:
This skill covers:
This skill does NOT cover:
store-submission skill)| Command | Purpose |
|---|---|
wxt | Start dev mode with HMR |
wxt build | Production build |
wxt build -b firefox | Firefox-specific build |
wxt zip | Package for distribution |
wxt prepare | Generate TypeScript types |
wxt clean | Clean output directories |
wxt submit | Publish to stores |
| Type | File | Manifest Key |
|---|---|---|
| Background | entrypoints/background.ts | background.service_worker |
| Content Script | entrypoints/content.ts | content_scripts |
| Popup | entrypoints/popup/ | action.default_popup |
| Options | entrypoints/options/ | options_page |
| Side Panel | entrypoints/sidepanel/ | side_panel |
| Unlisted | entrypoints/*.ts | Not in manifest |
my-extension/
├── entrypoints/
│ ├── background.ts # Service worker
│ ├── content.ts # Content script
│ ├── content/ # Multi-file content script
│ │ ├── index.ts
│ │ └── styles.css
│ ├── popup/
│ │ ├── index.html
│ │ ├── main.ts
│ │ └── App.vue
│ ├── options/
│ │ └── index.html
│ └── sidepanel/
│ └── index.html
├── public/
│ └── icon/
│ ├── 16.png
│ ├── 32.png
│ ├── 48.png
│ └── 128.png
├── utils/ # Shared utilities
├── wxt.config.ts # WXT configuration
├── tsconfig.json
└── package.json
// entrypoints/background.ts
export default defineBackground(() => {
console.log('Extension loaded', { id: browser.runtime.id });
// Handle messages from content scripts
browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'getData') {
handleGetData(message.payload).then(sendResponse);
return true; // Keep channel open for async response
}
});
// Use alarms for recurring tasks (MV3 service worker friendly)
browser.alarms.create('sync', { periodInMinutes: 5 });
browser.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === 'sync') {
performSync();
}
});
});
// entrypoints/content.ts
export default defineContentScript({
matches: ['*://*.example.com/*'],
runAt: 'document_idle',
main(ctx) {
console.log('Content script loaded');
// Use context for lifecycle management
ctx.onInvalidated(() => {
console.log('Extension updated/disabled');
cleanup();
});
// Create isolated UI
const ui = createShadowRootUi(ctx, {
name: 'my-extension-ui',
position: 'inline',
anchor: '#target-element',
onMount(container) {
// Mount your UI framework here
return mount(App, { target: container });
},
onRemove(app) {
app.$destroy();
},
});
ui.mount();
},
});
// entrypoints/content.ts
export default defineContentScript({
matches: ['*://*.example.com/*'],
world: 'MAIN', // Access page's JavaScript context
main() {
// Can access page's window object
window.myExtensionApi = {
getData: () => { /* ... */ }
};
},
});
<!-- entrypoints/popup/index.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
<div id="app"></div>
<script type="module" src="./main.ts"></script>
</body>
</html>
// entrypoints/popup/main.ts
import { createApp } from 'vue';
import App from './App.vue';
import './style.css';
createApp(App).mount('#app');
// wxt.config.ts
import { defineConfig } from 'wxt';
export default defineConfig({
srcDir: 'src',
entrypointsDir: 'src/entrypoints',
outDir: 'dist',
manifest: {
name: 'My Extension',
description: 'Extension description',
version: '1.0.0',
permissions: ['storage', 'activeTab'],
host_permissions: ['*://*.example.com/*'],
},
});
// wxt.config.ts
import { defineConfig } from 'wxt';
export default defineConfig({
manifest: ({ browser }) => ({
name: 'My Extension',
description: 'Cross-browser extension',
// Browser-specific settings
...(browser === 'firefox' && {
browser_specific_settings: {
gecko: {
id: 'my-extension@example.com',
strict_min_version: '109.0',
data_collection_permissions: {
required: [],
optional: ['technicalAndInteraction'],
},
},
},
}),
// Chrome-specific
...(browser === 'chrome' && {
minimum_chrome_version: '116',
}),
}),
});
// entrypoints/background.ts
export default defineBackground({
// Different behavior per browser
persistent: {
firefox: true, // Use persistent background in Firefox
chrome: false, // Service worker in Chrome
},
main() {
// ...
},
});
| # | Rule | Rationale |
|---|---|---|
| 1 | Minimize permissions | Request only what's needed |
| 2 | Use optional_permissions | Request sensitive permissions at runtime |
| 3 | Scope host_permissions | Narrow to specific domains, never <all_urls> |
| 4 | Set minimum_chrome_version | Ensure security features are available |
| 5 | Avoid externally_connectable wildcards | Limit which sites can message extension |
| 6 | Set strict CSP | No unsafe-eval, no external scripts |
| 7 | Use web_accessible_resources sparingly | Fingerprinting risk |
| 8 | Never expose source maps | Hide implementation details |
| 9 | Remove debug permissions in production | e.g., management, debugger |
| 10 | Validate manifest with wxt build --analyze | Catch permission bloat |
| # | Rule | Rationale |
|---|---|---|
| 11 | Use Shadow DOM for injected UI | Style isolation, DOM encapsulation |
| 12 | Never use innerHTML with untrusted data | XSS prevention |
| 13 | Validate all messages from page | Don't trust window.postMessage |
| 14 | Use ContentScriptContext for cleanup | Prevent memory leaks |
| 15 | Avoid storing sensitive data in DOM | Page scripts can read it |
| 16 | Use document_idle over document_start | Less intrusive, more stable |
| 17 | Scope CSS selectors narrowly | Avoid page conflicts |
| 18 | Never inject into banking/payment pages | High-risk surfaces |
| 19 | Use MutationObserver over polling | Performance |
| 20 | Validate URL before injecting | Prevent injection on wrong pages |
| # | Rule | Rationale |
|---|---|---|
| 21 | Persist state to chrome.storage | Service worker terminates |
| 22 | Use chrome.alarms over setInterval | Survives worker restart |
| 23 | Validate all incoming messages | Don't trust content scripts |
| 24 | Never store secrets in code | Use secure storage |
| 25 | Use HTTPS for all fetch requests | Data in transit security |
| 26 | Implement rate limiting | Prevent abuse |
| 27 | Log security events | Audit trail |
| 28 | Handle extension update gracefully | Reconnect content scripts |
| 29 | Use webRequest carefully | Performance impact |
| 30 | Avoid long-running operations | Service worker termination |
| # | Rule | Rationale |
|---|---|---|
| 31 | Use storage.local for sensitive data | Not synced to cloud |
| 32 | Encrypt sensitive values | Defense in depth |
| 33 | Implement storage quotas | Prevent unbounded growth |
| 34 | Validate data before storing | Type safety |
| 35 | Use versioned schema migrations | Data integrity |
| 36 | Clear storage on uninstall | User privacy |
| 37 | Don't store PII without consent | GDPR/CCPA compliance |
| 38 | Use storage.session for temporary data | Auto-cleared |
| 39 | Implement backup/restore | Data recovery |
| 40 | Audit storage access | Security logging |
| # | Rule | Rationale |
|---|---|---|
| 41 | Use runtime.sendMessage over postMessage | Type-safe, scoped |
| 42 | Validate sender in message handlers | Prevent spoofing |
| 43 | Never pass functions in messages | Serialization issues |
| 44 | Chunk large data transfers | Memory efficiency |
| 45 | Use typed message protocols | Maintainability |
| 46 | Implement request timeouts | Prevent hanging |
| 47 | Handle disconnection gracefully | Tab closed, extension disabled |
| 48 | Don't expose internal APIs externally | Use separate handlers |
| 49 | Log and monitor message patterns | Detect anomalies |
// wxt.config.ts
manifest: {
browser_specific_settings: {
gecko: {
// Required for AMO submission
id: 'my-extension@example.com',
// Version constraints
strict_min_version: '109.0',
// Data collection (required since Nov 2025)
data_collection_permissions: {
required: [],
optional: ['technicalAndInteraction'],
},
},
// Firefox for Android
gecko_android: {
strict_min_version: '120.0',
},
},
}
| Feature | Chrome MV3 | Firefox MV3 |
|---|---|---|
| Background | Service worker only | Event page supported |
| Persistent | No | Optional with persistent: true |
browser API | Promisified polyfill needed | Native promises |
| DNR | Full support | Partial support |
| Side Panel | Supported | Not supported |
# Build for Firefox only
wxt build -b firefox
# Build MV2 for Firefox (if needed)
wxt build -b firefox --mv2
// utils/browser-detect.ts
export const isFirefox = navigator.userAgent.includes('Firefox');
// entrypoints/background.ts
export default defineBackground({
persistent: isFirefox, // Keep background alive in Firefox
main() {
if (isFirefox) {
// Firefox-specific initialization
}
},
});
Safari extensions require an Xcode host app:
# Convert existing extension to Safari
xcrun safari-web-extension-converter /path/to/extension \
--project-location /path/to/output \
--app-name "My Extension" \
--bundle-identifier com.example.myextension
Every Safari extension host app needs PrivacyInfo.xcprivacy:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array/>
</dict>
</plist>
| Feature | Status | Workaround |
|---|---|---|
| Side Panel | Not supported | Use popup |
declarativeNetRequest | Limited | Use webRequest |
offscreen API | Not supported | Use content script |
| Persistent background | Not supported | State persistence |
chrome.scripting.executeScript | Limited | Declare in manifest |
# 1. Build extension
wxt build -b safari
# 2. Convert to Xcode project
xcrun safari-web-extension-converter dist/safari-mv3 \
--project-location safari-app
# 3. Open in Xcode
open safari-app/MyExtension.xcodeproj
# 4. Add PrivacyInfo.xcprivacy to host app target
# 5. Archive and submit to App Store
As of 2025, Safari extensions can be submitted as ZIP files to App Store Connect for TestFlight testing without needing Xcode locally.
// utils/storage.ts
import { storage } from 'wxt/storage';
// Define typed storage items
export const userSettings = storage.defineItem<{
theme: 'light' | 'dark';
notifications: boolean;
}>('local:settings', {
defaultValue: {
theme: 'light',
notifications: true,
},
});
export const sessionData = storage.defineItem<string[]>(
'session:recentTabs',
{ defaultValue: [] }
);
// Usage
const settings = await userSettings.getValue();
await userSettings.setValue({ ...settings, theme: 'dark' });
// Watch for changes
userSettings.watch((newValue, oldValue) => {
console.log('Settings changed:', newValue);
});
// utils/storage.ts
import { storage } from 'wxt/storage';
export const userPrefs = storage.defineItem('local:prefs', {
defaultValue: { version: 2, theme: 'system' },
migrations: [
// v1 -> v2: renamed 'darkMode' to 'theme'
{
version: 2,
migrate(oldValue: { darkMode?: boolean }) {
return {
version: 2,
theme: oldValue.darkMode ? 'dark' : 'light',
};
},
},
],
});
// tests/background.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fakeBrowser } from 'wxt/testing';
describe('background script', () => {
beforeEach(() => {
fakeBrowser.reset();
});
it('handles getData message', async () => {
// Setup fake response
fakeBrowser.storage.local.get.mockResolvedValue({ data: 'test' });
// Import and run background script
await import('../entrypoints/background');
// Simulate message
const [listener] = fakeBrowser.runtime.onMessage.addListener.mock.calls[0];
const response = await new Promise((resolve) => {
listener({ type: 'getData' }, {}, resolve);
});
expect(response).toEqual({ data: 'test' });
});
});
// tests/e2e/extension.test.ts
import { test, expect, chromium } from '@playwright/test';
import path from 'path';
test('popup shows correct UI', async () => {
const extensionPath = path.join(__dirname, '../../dist/chrome-mv3');
const context = await chromium.launchPersistentContext('', {
headless: false,
args: [
`--disable-extensions-except=${extensionPath}`,
`--load-extension=${extensionPath}`,
],
});
// Get extension ID
const [background] = context.serviceWorkers();
const extensionId = background.url().split('/')[2];
// Open popup
const popup = await context.newPage();
await popup.goto(`chrome-extension://${extensionId}/popup.html`);
await expect(popup.locator('h1')).toHaveText('My Extension');
});
npm audit)wxt build --analyze)# Build all browsers
wxt build -b chrome
wxt build -b firefox
wxt build -b safari
wxt build -b edge
# Package for submission
wxt zip -b chrome
wxt zip -b firefox