Writing a Strict Content Security Policy for MV3

Configure content_security_policy in a Manifest V3 extension — what the default already forbids, what you may tighten, sandbox pages, and the directives stores reject.

Published September 18, 2026 Updated September 18, 2026 9 min read
Table of Contents

Manifest V3 ships a CSP you cannot loosen in the ways that used to cause trouble: no unsafe-eval, no remote scripts, no inline handlers. What remains is a smaller decision — whether to tighten further, and how to handle the genuinely dynamic cases without reaching for the directives the platform has removed. This guide is part of extension security and CSP hardening.

What you start with

MV3’s default policy for extension pages is script-src 'self'; object-src 'self'. That single line rules out inline scripts, eval, new Function, remote <script src>, and dynamic import() of a remote URL. The browser enforces it; the store also scans for the patterns, so there is no version of “it works locally” that survives upload.

The policies that apply to extension codeFour distinct policies: the MV3 default for extension pages, an optional tightened policy, the sandbox policy for isolated pages, and the host page's own CSP which governs content scripts.extension_pages (default)script-src 'self'; object-src 'self'already blocks eval and remote codeextension_pages (tightened)add default-src, connect-src, img-srcoptional, and worth doingsandboxan isolated page, no chrome.* accessthe only place eval is possibleHost page CSPapplies to what a content script injectsnot yours to change
A content script obeys the page's CSP for anything it injects — which is why the same code can pass in an extension page and fail on a site.

Step-by-step

1. Tighten beyond the default

The default constrains scripts and nothing else. Adding the other fetch directives costs nothing and turns a class of mistakes into build-time errors.

 1{
 2  "content_security_policy": {
 3    "extension_pages":
 4      "default-src 'self'; \
 5       script-src 'self'; \
 6       object-src 'none'; \
 7       style-src 'self'; \
 8       img-src 'self' data:; \
 9       font-src 'self'; \
10       connect-src 'self' https://api.example.com; \
11       frame-src 'none'; \
12       base-uri 'none'; \
13       form-action 'none'"
14  }
15}

Execution context: parsed at install and enforced on every extension page — popup, options, side panel, offscreen document. Note connect-src is where your own API belongs: listing it explicitly means an accidental request to a different host is blocked rather than merely unreviewed.

2. Understand which directives are refused

Chrome rejects a manifest whose extension_pages policy is weaker than the default. In practice that means these are all fatal:

  • 'unsafe-eval' — rejected outright.
  • 'unsafe-inline' in script-src — rejected.
  • A remote origin in script-src — rejected.
  • 'wasm-unsafe-eval' — permitted, and the one exception; needed for WebAssembly.
1{
2  "content_security_policy": {
3    "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
4  }
5}

Execution context: parsed at install. 'wasm-unsafe-eval' allows WebAssembly compilation and nothing else; it does not re-enable eval for JavaScript. Declaring it when you do not ship WebAssembly is a question you will be asked in review.

3. Use a sandbox page for genuinely dynamic evaluation

If you must evaluate user-authored expressions — a formula field, a template — a sandboxed page is the supported route. It runs in a unique opaque origin with no chrome.* access and communicates only by postMessage.

1{
2  "sandbox": { "pages": ["sandbox/evaluator.html"] },
3  "content_security_policy": {
4    "sandbox": "sandbox allow-scripts; script-src 'self' 'unsafe-eval'; child-src 'self'"
5  }
6}

Execution context: parsed at install. The sandbox page has no extension privileges at all — no storage, no messaging APIs, no host permissions — which is precisely what makes the relaxed policy acceptable.

1// evaluator.html's script — no chrome.* here
2addEventListener("message", (e) => {
3  let result, error = null;
4  try { result = Function(`"use strict"; return (${e.data.expr})`)(); }
5  catch (err) { error = String(err); }
6  e.source.postMessage({ id: e.data.id, result, error }, e.origin);
7});

Execution context: the sandboxed page’s own opaque origin. Echoing back e.origin rather than "*" keeps the reply from leaking to a third party if the frame is ever embedded elsewhere.

4. Load the sandbox in an iframe and speak to it carefully

 1// options.js
 2const frame = document.createElement("iframe");
 3frame.src = chrome.runtime.getURL("sandbox/evaluator.html");
 4document.body.appendChild(frame);
 5
 6function evaluate(expr) {
 7  const id = crypto.randomUUID();
 8  return new Promise((resolve) => {
 9    const onMessage = (e) => {
10      if (e.data?.id !== id) return;
11      removeEventListener("message", onMessage);
12      resolve(e.data);
13    };
14    addEventListener("message", onMessage);
15    frame.contentWindow.postMessage({ id, expr }, "*");
16  });
17}

Execution context: an extension page. The frame-src 'none' in step 1 would block this, so a build that uses a sandbox needs frame-src 'self' — a good illustration of why the tightened policy should be written against what the extension actually does.

5. Remember the content script is governed by the page

A content script’s own code runs in the isolated world and is not subject to the page’s CSP. Anything it injects into the page is. A <script src="..."> element added to the DOM is fetched and executed under the page’s policy, and on a site with a strict CSP it will be refused.

 1// Blocked on a site with a strict script-src
 2const s = document.createElement("script");
 3s.src = chrome.runtime.getURL("page/bridge.js");
 4document.documentElement.appendChild(s);
 5
 6// Not blocked: the scripting API's MAIN world bypasses the page CSP
 7await chrome.scripting.executeScript({
 8  target: { tabId },
 9  world: "MAIN",
10  files: ["page/bridge.js"],
11});

Execution context: the first block runs in the content script’s isolated world and creates an element the page’s CSP governs; the second runs from the service worker and injects directly, which the page’s policy does not apply to. The distinction is developed in bridging data between main world and isolated world.

What each directive buys youSix CSP directives with what the MV3 default provides, what a tightened policy adds, and the mistake each one catches.DirectiveMV3 defaultTightenedCatchesscript-src'self''self'eval, remote scriptsobject-src'self''none'Plugin embedsconnect-srcUnrestricted'self' + your APIExfiltration to a new hostimg-srcUnrestricted'self' data:Remote tracking pixelsframe-srcUnrestricted'none' or 'self'Embedded third-party UIbase-uriUnrestricted'none'Base-tag hijacking
The default only covers the first two rows — everything below is opt-in and cheap.

Rolling out a tighter policy without breaking a release

Tightening a CSP is a change that either does nothing or breaks a feature, and you will not know which until the code path runs. Because a violation is only reported in the console of the page where it happened, a directive that blocks something in the options page can ship unnoticed.

The safe sequence has three steps.

First, inventory what the extension actually fetches. Load each surface with DevTools open and record every request and every resource type. The list is usually shorter than expected — one API host, packaged fonts, a couple of data: images.

1// Paste into each extension page's console to enumerate what it loaded.
2performance.getEntriesByType("resource")
3  .map((e) => [e.initiatorType, new URL(e.name).origin])
4  .filter(([, origin], i, arr) => arr.findIndex(([, o]) => o === origin) === i);

Execution context: each extension page’s own console — popup, options, side panel and any offscreen document, separately. The resource timing buffer only covers the current document, which is why each surface needs its own pass.

Second, write the policy from the inventory and test every surface. A directive omitted because a surface was forgotten is the common failure; offscreen documents in particular are easy to miss because nobody opens them by hand.

Third, ship it in a release with nothing else in it. A CSP change is the kind of thing that is trivially reverted if it is the only change, and a nightmare to bisect if it shipped alongside a feature.

There is no report-only mode for an extension’s extension_pages policy — the Content-Security-Policy-Report-Only header has no manifest equivalent — so the inventory is the dry run. That is worth knowing before planning a staged rollout that the platform cannot support.

Tightening a policy safelyEach extension surface is inventoried for the resources it loads, a policy is derived from the union, every surface is retested, and the change ships on its own.Inventory each surfacepopup, options, panel, offscreenUnion the originsper directiveWrite the policynarrowest that covers itthen prove it on every surface againRetest all surfacesconsole must be cleanShip alonetrivially revertibleWatch error reportsviolations look like crashes
There is no report-only mode here, so the inventory step is the only dry run available.

Cross-browser variation

  • Chrome / Edge: enforces the MV3 default and rejects any weakening of extension_pages. 'wasm-unsafe-eval' is accepted. Sandboxed pages behave as described.
  • Firefox: enforces an equivalent default and also rejects 'unsafe-eval' in MV3. Firefox’s CSP parsing is slightly stricter about whitespace in the manifest string — keep it on one logical line or use explicit concatenation in your build.
  • Safari: applies WebKit’s CSP implementation; sandboxed pages are supported but the messaging round trip is slower. Safari has historically been less consistent about reporting violations, so test policy changes in Chrome first.
  • All three: a violation in an extension page is reported only in that page’s own console. A popup violation will not appear on the extensions page — the trap described in debugging a popup that renders blank.

Verification

  1. Confirm the policy the browser actually applied:
1await (await fetch(chrome.runtime.getURL("manifest.json"))).json()
2  .then((m) => m.content_security_policy);
3// { extension_pages: "default-src 'self'; script-src 'self'; …" }

Execution context: any extension page. Reading the manifest back is worth doing after a build that generates it — a policy string mangled by a templating step is otherwise invisible until something is blocked.

  1. Add a deliberate violation — an <img src="https://example.com/x.png"> in the options page — and confirm the console reports it and the tightened img-src blocked it.
  2. Run grep -rn "eval(\|new Function" dist/ over the built bundle and confirm no hits.
  3. Exercise every network call the extension makes and confirm none are blocked by connect-src.

FAQ

Can I allow a CDN for fonts or styles?

You can add an origin to font-src or style-src, but you should not: remote assets make the extension depend on a third party at runtime and complicate review. Bundle the font.

Does the CSP apply to the service worker?

The worker is governed by the same restrictions — no eval, no remote imports — but it is not an extension page, so some directives such as img-src are irrelevant there. connect-src does apply to its fetches.

Why is my data: image blocked?

Because img-src 'self' does not include data:. Add data: explicitly, as in the example above, or convert the image to a packaged file.

Other MV3 Architecture & Extension Lifecycle Resources