Chrome Extension Content Security Policy — Manifest V3 Guide

15 min read

MV3 Content Security Policy

A comprehensive guide to Content Security Policy changes in Manifest V3 and how to migrate your extension.

Overview

Manifest V3 (MV3) enforces a stricter Content Security Policy (CSP) compared to Manifest V2. The most significant changes are:

These changes improve security but require code refactoring for many extensions.


Key Changes Table

Feature MV2 MV3 Notes
eval() ✅ Allowed ❌ Blocked Use JSON.parse or a proper parser
new Function() ✅ Allowed ❌ Blocked Use alternative implementations
Remote scripts ✅ Allowed ❌ Blocked Bundle all dependencies locally
Inline scripts ✅ Allowed ❌ Blocked Move to separate .js files
Data URIs ✅ Allowed ⚠️ Restricted Allowed in some contexts
WebAssembly ⚠️ Limited ✅ Allowed Enabled by default in MV3

Default MV3 CSP

Manifest V3 extensions have this default CSP:

script-src 'self' 'wasm-unsafe-eval'; object-src 'self';

Breaking it down:

You can customize this in manifest.json:

{
  "content_security_policy": {
    "extension_pages": "script-src 'self'; object-src 'self'",
    "sandbox": "sandbox allow-scripts allow-same-origin"
  }
}

Manifest Configuration

In MV3, CSP is now an object with two keys:

{
  "content_security_policy": {
    "extension_pages": "script-src 'self'; object-src 'self'",
    "sandbox": "sandbox allow-scripts allow-same-origin"
  }
}
Key Description
extension_pages CSP for extension popup, background script, options page
sandbox CSP for sandboxed pages (used for dynamic code)

Problem 1: eval() and new Function()

These are the most common migration issues. Both are blocked in MV3.

❌ MV2 (Will Fail in MV3)

// Using eval() to parse JSON
const data = eval('(' + jsonString + ')');

// Using new Function() for dynamic code
const fn = new Function('return ' + jsonString);

✅ MV3 Compatible

// Use JSON.parse for JSON
const data = JSON.parse(jsonString);

// Use a proper parser or template engine
import { parse } from './parser.js';
const data = parse(jsonString);

Replacing Template Engines

If a library uses eval() internally, consider alternatives:

Library MV2 MV3 Alternative
Handlebars Use precompiled templates
Lodash templates Use precompiled or esbuild
Underscore.js Use precompiled templates
Custom eval Use JSON.parse or a parser

Problem 2: Remote Scripts

MV3 requires all scripts to be bundled locally. No loading from CDN.

❌ MV2 (Will Fail in MV3)

<script src="https://cdn.example.com/library.js"></script>
// In manifest.json (MV2)
"content_scripts": [{
  "js": ["https://cdn.example.com/library.js"]
}]

✅ MV3 Compatible

  1. Download the dependency
  2. Place it in your extension
  3. Reference locally
npm install library-name
cp node_modules/library-name/dist/library.js extension/lib/
// manifest.json (MV3)
{
  "content_scripts": [{
    "js": ["lib/library.js", "content.js"]
  }]
}

Bundling with Build Tools

For complex dependencies, use a bundler:

// rollup.config.js
export default {
  input: 'src/background.js',
  output: {
    file: 'dist/background.js',
    format: 'iife'
  },
  external: ['chrome']
};

Problem 3: Inline Scripts

Inline scripts (<script> tags and inline event handlers) are blocked.

❌ MV2 (Will Fail in MV3)

<!-- Inline script -->
<script>
  console.log('Hello');
</script>

<!-- Inline event handler -->
<button onclick="handleClick()">Click</button>

<!-- Inline style with expression -->
<style>
  body { width: calc(100% - 10px); }
</style>

✅ MV3 Compatible

<!-- Move to external file -->
<script src="popup.js"></script>

<!-- Use addEventListener -->
<button id="myButton">Click</button>
// popup.js
document.getElementById('myButton').addEventListener('click', handleClick);

Converting Inline Event Handlers

Inline (MV2) External (MV3)
onclick="fn()" element.addEventListener('click', fn)
onload="init()" window.addEventListener('load', init)
onerror="handle()" element.addEventListener('error', handle)

Problem 4: Dynamic Code Generation

If you absolutely need dynamic code execution, use sandboxed pages.

Architecture

┌─────────────────────────────────────┐
│  Extension Page                     │
│  (CSP: script-src 'self')          │
│         │                           │
│         │ postMessage               │
│         ▼                           │
│  ┌─────────────────────────────┐   │
│  │  Sandbox Page               │   │
│  │  (CSP: sandbox allow-scripts)│   │
│  │  - Can use eval/Function    │   │
│  │  - Returns results via      │   │
│  │    postMessage              │   │
│  └─────────────────────────────┘   │
└─────────────────────────────────────┘

Implementation

manifest.json:

{
  "sandbox": {
    "pages": ["sandbox.html"]
  }
}

sandbox.html:

<!DOCTYPE html>
<html>
<head>
  <script src="sandbox.js"></script>
</head>
<body>
  <!-- Sandbox runs in isolation -->
</body>
</html>

sandbox.js:

// This runs in sandbox with relaxed CSP
window.addEventListener('message', (event) => {
  if (event.source !== window) return;
  
  const { code } = event.data;
  try {
    const result = eval(code);
    window.parent.postMessage({ result }, '*');
  } catch (error) {
    window.parent.postMessage({ error: error.message }, '*');
  }
});

Extension page (popup.js):

function executeInSandbox(code) {
  return new Promise((resolve, reject) => {
    const channel = new MessageChannel();
    
    window.parent.postMessage({ code }, '*', [channel.port2]);
    
    channel.port1.onmessage = (event) => {
      if (event.data.error) {
        reject(new Error(event.data.error));
      } else {
        resolve(event.data.result);
      }
    };
    
    // Use sandbox page via iframe
    const iframe = document.createElement('iframe');
    iframe.src = 'sandbox.html';
    iframe.style.display = 'none';
    document.body.appendChild(iframe);
  });
}

Note: The @theluckystrike/webext-messaging library handles all this complexity for you with a clean, CSP-safe API.


Using with @theluckystrike/webext-messaging

The @theluckystrike/webext-messaging library is designed to be fully CSP-compliant. It uses native chrome.runtime.sendMessage and chrome.runtime.onMessage APIs—no eval(), no inline scripts, no dynamic code.

Installation

npm install @theluckystrike/webext-messaging

Basic Usage

import { createMessenger } from "@theluckystrike/webext-messaging";

// Create messenger for background script communication
const messenger = createMessenger({
  context: 'content', // or 'background', 'popup', 'options'
  debug: false
});

// Send messages (CSP-safe, no eval)
async function getData() {
  const response = await messenger.send('get-data', { key: 'value' });
  return response;
}

// Listen for messages
messenger.on('data-updated', (data) => {
  console.log('Data received:', data);
});

Why it’s CSP-safe


Using with @theluckystrike/webext-storage

The @theluckystrike/webext-storage library provides CSP-compliant storage operations using Chrome’s chrome.storage API.

Installation

npm install @theluckystrike/webext-storage

Basic Usage

import { defineSchema, createStorage } from "@theluckystrike/webext-storage";

// Define your storage schema
const schema = defineSchema({
  settings: {
    theme: { type: 'string', default: 'light' },
    notifications: { type: 'boolean', default: true }
  },
  cache: {
    data: { type: 'object' }
  }
});

// Create storage instance
const storage = createStorage(schema);

// CSP-safe operations
async function saveSettings(settings) {
  await storage.set('settings', settings);
}

async function loadSettings() {
  const settings = await storage.get('settings');
  return settings;
}

Why it’s CSP-safe


Content Scripts CSP

Content scripts have a unique relationship with the host page’s CSP.

Page’s CSP vs Extension’s CSP

Context CSP Applied
Host page Page’s CSP (you can’t control this)
Content script Not subject to the page’s CSP; content scripts can use chrome.runtime APIs freely
Extension pages Extension’s CSP (your content_security_policy)

Implications

  1. Content scripts run in an isolated world and are not restricted by the host page’s CSP
  2. Messages to extension use chrome.runtime.sendMessage (CSP-exempt)
  3. Inline handlers in page — You cannot use them, use addEventListener

❌ MV2 (Will Fail in MV3)

<!-- Inline event handler in HTML -- blocked by CSP -->
<button onclick="chrome.runtime.sendMessage({ action: 'doSomething' })">Click</button>

✅ MV3 Compatible

// Content script or extension page -- use addEventListener or .onclick property
document.querySelector('button').addEventListener('click', () => {
  chrome.runtime.sendMessage({ action: 'doSomething' });
});

Note: JavaScript .onclick property assignment is NOT blocked by CSP. Only HTML onclick="..." attribute inline handlers are blocked.


WebAssembly

WebAssembly (Wasm) is allowed by default in MV3.

Default CSP Includes Wasm

script-src 'self' 'wasm-unsafe-eval'; object-src 'self';

The 'wasm-unsafe-eval' directive allows Wasm compilation.

Using Wasm in Your Extension

// Load Wasm module
async function loadWasmModule() {
  const response = await fetch(chrome.runtime.getURL('module.wasm'));
  const buffer = await response.arrayBuffer();
  const module = await WebAssembly.compile(buffer);
  const instance = await WebAssembly.instantiate(module);
  return instance.exports;
}

If You Need to Disable Wasm

For maximum security, you can remove 'wasm-unsafe-eval':

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

Warning: This will break any Wasm-based functionality in your extension.


Common Libraries That Need Fixes

Some popular libraries have features that violate MV3’s CSP. Here’s how to handle them:

Library Issue Solution
Handlebars Uses eval for runtime compilation Precompile templates, use handlebars-runtime
Lodash (template) Uses new Function for templates Precompile templates
jQuery Works fine Use latest version
Angular.js (1.x) Uses ng-bind with expressions Refactor to use components
Moment.js Works fine Use latest version
Underscore.js Uses new Function for templates Precompile templates
CoffeeScript Compiles to eval Precompile to JS
Babel (runtime) May use eval Use @babel/standalone precompiled

Handlebars Migration Example

// ❌ MV2 - Runtime compilation (uses eval)
const template = Handlebars.compile('<div>{{name}}</div>');
const html = template({ name: 'World' });

// ✅ MV3 - Precompiled templates
import template from './templates/hello.js';
const html = template({ name: 'World' });
# Precompile Handlebars
npx handlebars template.hbs -f templates/hello.js

Migration Checklist

Use this checklist when migrating from MV2 to MV3:

Phase 1: Inventory

Phase 2: Replace Dynamic Code

Phase 3: Bundle Dependencies

Phase 4: Verify

Phase 5: Use CSP-Safe Libraries


Summary

MV3’s stricter CSP significantly improves extension security but requires migration effort:

  1. No dynamic code — Replace eval() and new Function()
  2. No remote scripts — Bundle everything locally
  3. No inline scripts — Use external files and addEventListener
  4. Use sandbox pages — For rare cases requiring dynamic code
  5. Use @theluckystrike libraries — They’re designed for CSP compliance

The @theluckystrike/webext-messaging and @theluckystrike/webext-storage libraries provide clean, CSP-safe APIs that work out of the box with Manifest V3. -e —

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