Fixing CSP Violations in Extension Pages

Read and clear content security policy errors in MV3 extension pages: which console they appear in, the four common causes, and how to keep a bundler from reintroducing eval.

Published July 25, 2026 Updated July 25, 2026 8 min read
Table of Contents

Your popup renders blank, or a button does nothing, and there is no error in the console you have open — because extension surfaces each have their own console, and the violation was logged in a different one. Content security policy failures in MV3 are loud once you are looking in the right place, and almost all of them reduce to four causes. This page is part of Extension Security & CSP Hardening and works through finding the message and fixing what produced it.

Root cause: the violation is reported where the code ran

An extension has several independent renderers — popup, options page, offscreen document, each content script’s page — plus the service worker. A policy violation is reported in the console belonging to the context that tried the forbidden thing, and there is no aggregate view. A blank popup with a clean page console is therefore the expected symptom of a popup script that was refused, not evidence that nothing went wrong.

Where to look for the violation, by surfaceEach extension surface reports policy violations in its own DevTools window, reached by a different route.Popupright-click → Inspect popupOptions pageordinary tab DevToolsthe background surfaces are reached differently againService workerextensions page → service workerOffscreen documentextensions page → inspect views
Four surfaces, four consoles — the first debugging step is opening the right one, not reading the code.

Step 1. Read the message and identify the directive

Every violation names the directive that refused it. That single word tells you which of the four causes you have, and there is no need to guess further.

Violation text to causeThe four common MV3 policy violations, the directive each names, what triggered it and the fix.Directive in the messageTriggered byFixscript-src (inline)An inline <script> blockMove it to a filescript-src (unsafe-eval)eval, new Function, string setTimeoutRemove or sandbox itscript-src (remote host)A CDN <script src>Bundle it locallyconnect-srcfetch to an undeclared originAdd the origin or drop the callimg-src / font-srcA remote asset referencePackage the asset
The directive in the message is diagnostic on its own — match it here before reading any of your own code.

Step 2. Remove inline script and inline handlers

This is the most common cause and the easiest to miss, because an onclick attribute is not obviously a script. Every handler has to be attached from an external file.

1<!-- popup.html — before: both lines are refused -->
2<button onclick="save()">Save</button>
3<script>document.title = "Ready";</script>
4
5<!-- popup.html — after: markup only, behaviour in a file -->
6<button id="save">Save</button>
7<script src="popup.js"></script>
1// popup.js
2document.title = "Ready";
3document.querySelector("#save").addEventListener("click", () => { void save(); });

Execution context: Popup renderer under the extension policy. The onclick attribute is refused as an inline script even though it never appears inside a <script> tag, which is why a page can look completely script-free and still produce violations. Template engines that interpolate handlers into markup have the same problem; a build that emits onclick attributes will fail in an extension page regardless of how the source looked.

Step 3. Find the eval your bundler added

Developers rarely write eval, but bundlers emit it — most often in a development configuration, where eval-based source maps make rebuilds faster. The symptom is an extension that works when built for production and fails when built for development, which is the wrong way round for a debugging workflow.

 1// vite.config.js — an extension-safe development configuration
 2export default {
 3  build: {
 4    // 'eval' and 'eval-source-map' style output is refused by the extension policy.
 5    sourcemap: true,        // external .map files, not inline eval wrappers
 6    minify: false,
 7    rollupOptions: {
 8      output: { format: "es", entryFileNames: "[name].js" },
 9    },
10  },
11  // Serving from a dev server does not apply: an extension loads files from disk.
12  define: { "process.env.NODE_ENV": JSON.stringify("development") },
13};

Execution context: Build configuration, consumed by the bundler in Node. The format: "es" choice matters alongside the policy: a service worker declared with "type": "module" needs ES module output, and a bundler emitting a UMD wrapper will fail for a different reason that looks similar in the console. Verify by grepping the built output for eval( rather than trusting the configuration — that grep is worth adding to CI so a configuration change cannot reintroduce it silently.

Step 4. Replace remote scripts and remote assets

Remote code is refused by policy and rejected at store review, so there is no configuration that makes a CDN reference work. Assets are more subtle: a remote font or image is refused by font-src or img-src and produces a page that renders with the wrong typography rather than an obvious failure.

1// Before: a remote font and a remote icon, both refused.
2// @import url("https://fonts.example.com/inter.css");
3// <img src="https://cdn.example.com/icon.svg">
4
5// After: both packaged. Fonts are declared locally and icons resolved through the extension URL.
6const icon = document.createElement("img");
7icon.src = chrome.runtime.getURL("icons/status.svg");
8icon.alt = "Sync status";

Execution context: Any extension page. chrome.runtime.getURL produces a chrome-extension:// URL that the policy permits, and using it rather than a relative path keeps the reference correct if the page moves. Packaging a font adds bytes to the bundle, which is a real cost — but the alternative is a page whose appearance depends on a network request that the browser will refuse.

Step 5. Keep the policy from regressing

A policy violation is a runtime failure, so it will not appear in a type check or a unit test. Two cheap guards catch almost everything: a grep of the built output, and a headless load of each extension page with console errors treated as failures.

When each guard catches a policy regressionA build scan catches it in seconds, a headless page load catches it in the test run, and without either it surfaces after release.code changeusers affectedBuild scangrep the o…Headless page loadconsole errors fail the r…Manual testingif anyone opens that surfaceReleasedblank popup for userscheapest possible catchthe surface nobody opened in review
The same defect costs seconds, minutes or a release depending only on which guard is in place.
 1// scripts/check-csp.mjs — run against the built extension
 2import { readFile } from "node:fs/promises";
 3import { globby } from "globby";
 4
 5const files = await globby(["dist/**/*.{js,html}"]);
 6const problems = [];
 7
 8for (const file of files) {
 9  const text = await readFile(file, "utf8");
10  if (/\beval\s*\(|new\s+Function\s*\(/.test(text)) problems.push(`${file}: eval`);
11  if (/<script(?![^>]*\bsrc=)/i.test(text)) problems.push(`${file}: inline script`);
12  if (/\son[a-z]+\s*=\s*["']/i.test(text)) problems.push(`${file}: inline handler`);
13  if (/(?:src|href)\s*=\s*["']https?:\/\//i.test(text)) problems.push(`${file}: remote reference`);
14}
15
16if (problems.length) {
17  console.error(problems.map((p) => `✗ ${p}`).join("\n"));
18  process.exit(1);
19}
20console.log("✓ no policy violations in the build output");

Execution context: Node on a developer machine or a CI runner, reading the built directory. Scanning the build rather than the source is the whole point — the source can be clean while the output is not. The patterns are deliberately blunt and will occasionally flag a string in your own code that happens to look like a handler attribute; treat a hit as a prompt to look rather than as proof, and keep the check in the pipeline described in CI and release automation.

Cross-browser variation

  • Chrome 120+: violations are logged with the full directive and the blocked URL. extension_pages cannot be loosened, and attempting it fails the manifest at load.
  • Firefox 121+: enforces the same default and reports violations similarly, though the message wording differs. Its console groups extension page errors under the add-on in the Browser Toolbox, which is easier to find than Chrome’s per-surface windows.
  • Safari 17+: enforces the policy but reports violations more sparsely; a refused inline handler can produce silence rather than an error. Develop against Chrome or Firefox and treat Safari as verification.
  • Content scripts, all engines: run under the host page’s policy, not yours. A strict page can block your injected styles or images even though your own policy would allow them.

Verification

  1. Open each extension surface’s own console — popup, options, offscreen, service worker — and confirm all four are clean after a change, not just the one you were working in.
  2. Run the build scan above and confirm it fails when you deliberately add an onclick attribute, then passes once removed.
  3. Load the extension unpacked from the built output rather than serving it from a dev server; only the built form is subject to the real policy.
  4. Point a fetch at an origin missing from connect-src and confirm you get a policy error rather than a network error, so the two are not confused later.

FAQ

Can I add unsafe-eval just for development?

No — the manifest key is rejected outright, so there is no development-only escape hatch. Configure the bundler to emit external source maps instead; that is the change that makes development builds loadable.

Why does my popup show nothing at all rather than a partial render?

Because a refused script never runs, so nothing populates the markup. A blank popup with valid HTML is almost always a refused script, and the popup’s own console will say which.

Do content scripts follow my extension’s policy?

No, they follow the host page’s. That is why an injected style can vanish on one site and work on another, and why a sandbox directive on a page can restrict what your content script can do there.

Is there a way to see all violations in one place?

Not directly. The nearest thing is a headless load of each extension page in a test that fails on console errors, which is what turns four consoles into one signal.

Other MV3 Architecture & Extension Lifecycle Resources