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.
Table of Contents
- Root cause: the violation is reported where the code ran
- Step 1. Read the message and identify the directive
- Step 2. Remove inline script and inline handlers
- Step 3. Find the eval your bundler added
- Step 4. Replace remote scripts and remote assets
- Step 5. Keep the policy from regressing
- Cross-browser variation
- Verification
- FAQ
- Related
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.
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.
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.
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_pagescannot 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
- 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.
- Run the build scan above and confirm it fails when you deliberately add an
onclickattribute, then passes once removed. - 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.
- Point a
fetchat an origin missing fromconnect-srcand 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.
Related
- Safely using remote config without remote code — the supported way to change behaviour after shipping.
- Extension Security & CSP Hardening — the guide this page belongs to.
- Debugging extension contexts — opening each of those consoles.
- Building responsive popups with Tailwind CSS — an extension-safe build configuration in full.