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.
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.
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'inscript-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.
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.
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
- 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.
- Add a deliberate violation — an
<img src="https://example.com/x.png">in the options page — and confirm the console reports it and the tightenedimg-srcblocked it. - Run
grep -rn "eval(\|new Function" dist/over the built bundle and confirm no hits. - 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.
Related
- Fixing CSP violations in extension pages — reading and resolving a reported violation.
- Safely using remote config without remote code — the supported way to change behaviour after publishing.
- Sanitising untrusted page data in an extension — the threat CSP does not cover.
- Extension security and CSP hardening — the parent guide.