Manifest V3 is the latest version of the browser extension platform, bringing significant architectural changes focused on security, privacy, and performance.
What is Manifest V3?
Manifest V3 is a major update to the browser extension platform that fundamentally changes how extensions work:
- Service Workers replace persistent background pages
- Enhanced security with stricter content security policies
- Improved privacy with declarative APIs
- Better performance through resource management
Browser Support
| Browser | Manifest V3 Support | Default |
|---|
| Chrome | ✅ Required (102+) | V3 |
| Edge | ✅ Required | V3 |
| Opera | ✅ Required | V3 |
| Firefox | ✅ Optional (109+) | V2 |
| Safari | ✅ Optional (15.4+) | V2 |
Chrome and Edge require Manifest V3 as of 2024. Firefox and Safari still support Manifest V2 but are transitioning to V3.
Key Differences from Manifest V2
Background Pages → Service Workers
The most significant change is the replacement of persistent background pages with service workers.
Manifest V2 (Legacy)
// manifest.json (V2)
{
"manifest_version": 2,
"background": {
"page": "background.html",
"persistent": true
}
}
Background page runs continuously and has access to DOM, window, and document.
Manifest V3 (Current)
// manifest.v3.json
{
"manifest_version": 3,
"background": {
"service_worker": "background.js"
}
}
Service worker:
- Can be terminated at any time by the browser
- No access to DOM,
window, or document
- Must use message passing for all communication
- Automatically restarted when needed
Architecture Implications
Critical: Service workers can be terminated anytime. Do not assume the background context persists indefinitely. Use message passing for all communication and store state in chrome.storage.
In Manifest V2, this worked:
// ❌ No longer works in Manifest V3
const backgroundPage = chrome.extension.getBackgroundPage();
backgroundPage.vault.getCiphers();
In Manifest V3, use message passing:
// ✅ Correct approach for Manifest V3
const ciphers = await BrowserApi.sendMessageWithResponse<Cipher[]>("getCiphers");
From browser-api.ts:434:
/**
* Gets the background page for the extension. This method is
* not valid within manifest v3 background service workers. As
* a result, it will return null when called from that context.
*/
static getBackgroundPage(): any {
if (typeof chrome.extension.getBackgroundPage === "undefined") {
return null;
}
return chrome.extension.getBackgroundPage();
}
Action API Changes
Manifest V2
{
"browser_action": {
"default_icon": "images/icon19.png",
"default_title": "Bitwarden",
"default_popup": "popup/index.html"
}
}
API: chrome.browserAction
Manifest V3
{
"action": {
"default_icon": {
"19": "images/icon19.png",
"38": "images/icon38.png"
},
"default_title": "Bitwarden",
"default_popup": "popup/index.html"
}
}
API: chrome.action
The BrowserApi handles this automatically:
// From browser-api.ts:704
/**
* Returns the supported BrowserAction API based on the manifest version.
*/
static getBrowserAction() {
return BrowserApi.isManifestVersion(3) ? chrome.action : chrome.browserAction;
}
Permissions Changes
Host Permissions
Manifest V3 separates host permissions from regular permissions.
Manifest V2:
{
"permissions": [
"<all_urls>",
"*://*/*",
"tabs",
"storage"
]
}
Manifest V3:
{
"permissions": [
"tabs",
"storage",
"activeTab"
],
"host_permissions": [
"https://*/*",
"http://*/*"
]
}
Host permissions are now requested separately and can be optional.
New Permissions
Offscreen Permission:
{
"permissions": [
"offscreen" // Manifest V3 only
]
}
Used for clipboard operations and other DOM-dependent tasks in service workers.
Scripting Permission:
{
"permissions": [
"scripting" // Replaces chrome.tabs.executeScript
]
}
Content Security Policy
Manifest V2:
{
"content_security_policy": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
}
Manifest V3:
{
"content_security_policy": {
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
}
}
CSP is now an object with separate policies for extension pages and sandboxed pages.
Web Request API Changes
Manifest V3 deprecates blocking webRequest in favor of declarativeNetRequest.
Manifest V2:
{
"permissions": [
"webRequest",
"webRequestBlocking"
]
}
Manifest V3:
{
"permissions": [
"webRequest",
"webRequestAuthProvider" // No longer blocking
]
}
Bitwarden uses webRequestAuthProvider for HTTP Basic Auth interception, which is still allowed in MV3.
Service Worker Lifecycle
Startup and Termination
Service workers follow an event-driven lifecycle:
// Service worker starts
self.addEventListener('install', (event) => {
console.log('Service worker installing...');
});
self.addEventListener('activate', (event) => {
console.log('Service worker activated');
});
// Service worker can be terminated after ~30 seconds of inactivity
// It will restart when:
// - Extension receives a message
// - User interacts with extension
// - Alarm fires
// - Event listener is triggered
State Persistence
Do not store state in global variables. Service workers can be terminated, losing all in-memory state. Use chrome.storage for persistence.
❌ Wrong - Lost on termination:
// Global state - LOST when service worker terminates
let vaultTimeout = 15;
let isLocked = false;
✅ Correct - Persisted in storage:
// Store in chrome.storage
await chrome.storage.session.set({ vaultTimeout: 15 });
await chrome.storage.local.set({ isLocked: false });
// Retrieve when needed
const { vaultTimeout } = await chrome.storage.session.get('vaultTimeout');
Keeping Service Worker Alive
Use alarms for periodic tasks:
// Set recurring alarm
chrome.alarms.create('vaultTimeoutCheck', {
periodInMinutes: 1
});
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === 'vaultTimeoutCheck') {
// Check vault timeout
}
});
Migration Strategies
1. Replace Background Page Access
Before (V2):
const bg = chrome.extension.getBackgroundPage();
const ciphers = bg.getCiphers();
After (V3):
const ciphers = await BrowserApi.sendMessageWithResponse('getCiphers');
2. Use Offscreen Documents for DOM
Service workers cannot access DOM. Use offscreen documents:
// Create offscreen document
await chrome.offscreen.createDocument({
url: 'offscreen.html',
reasons: ['CLIPBOARD'],
justification: 'Write to clipboard'
});
// Send message to offscreen document
await BrowserApi.sendMessageWithResponse('offscreenCopyToClipboard', { text });
3. Update Script Injection
Before (V2):
chrome.tabs.executeScript(tabId, {
file: 'content.js'
});
After (V3):
await chrome.scripting.executeScript({
target: { tabId },
files: ['content.js']
});
BrowserApi handles this automatically:
await BrowserApi.executeScriptInTab(tabId, { file: 'content.js' });
4. Handle Service Worker Termination
Design services to be stateless or restore state quickly:
class VaultService {
private ciphers: Cipher[] | null = null;
async getCiphers(): Promise<Cipher[]> {
// Restore from storage if service worker restarted
if (this.ciphers === null) {
await this.restoreState();
}
return this.ciphers;
}
private async restoreState() {
const data = await chrome.storage.session.get('ciphers');
this.ciphers = data.ciphers || [];
}
}
Building for Different Manifest Versions
Chrome/Edge (V3 Only)
npm run build:chrome
npm run build:edge
These browsers only support Manifest V3.
Firefox (V2 or V3)
# Manifest V2 (default)
npm run build:firefox
# Manifest V3
cross-env MANIFEST_VERSION=3 npm run build:firefox
Safari (V2 or V3)
# Manifest V2 (default)
npm run build:safari
# Manifest V3
cross-env MANIFEST_VERSION=3 npm run build:safari
Testing Manifest V3
Test service worker termination resilience:
Load in Chrome
- Navigate to
chrome://extensions/
- Enable Developer mode
- Load unpacked extension from
build/
Open Service Worker DevTools
- Click “service worker” link in extension card
- DevTools opens for background service worker
Test Termination
- Click “Stop” in service worker DevTools
- Interact with extension (open popup, autofill)
- Service worker should restart and function correctly
Common Migration Issues
Issue: Background Page Returns Null
Symptom:
const bg = chrome.extension.getBackgroundPage(); // null
Solution:
Use message passing instead:
const result = await BrowserApi.sendMessageWithResponse('getData');
Issue: DOM Not Available
Symptom:
document.createElement('div'); // ReferenceError: document is not defined
Solution:
Use offscreen documents or move DOM operations to content scripts:
await BrowserApi.sendMessageWithResponse('offscreenCreateElement');
Issue: State Lost After Inactivity
Symptom:
Global variables reset to initial values after 30 seconds.
Solution:
Store state in chrome.storage:
// Persist state
await chrome.storage.session.set({ myState: value });
// Restore state
const { myState } = await chrome.storage.session.get('myState');
Resources
Next Steps
- Architecture - Understanding the extension architecture
- Building - Build commands for different browsers