Skip to content

Fix "Refused to execute inline script" in a Chrome extension (MV3 CSP)

7 min readModerok team

Why a Chrome extension throws "Refused to execute inline script", the external-file fix, and what still breaks after you move the code out.

If your popup or options page logs Refused to execute inline script because it violates the following Content Security Policy directive: "script-src 'self'", move the code into its own file and load it with src:

<!-- popup.html -->
<body>
  <button id="save">Save</button>
  <script src="popup.js"></script>
</body>

No manifest key switches inline script back on for an ordinary extension page. Chrome's Content Security Policy reference says the extension_pages policy "cannot be relaxed beyond this minimum value. In other words, you cannot add other script sources to directives", so 'unsafe-inline', a nonce, and a hash are all dead ends. The rest of this post is why, what breaks next, and the one page type that plays by different rules.

Why a Chrome extension refused to execute inline script

Every extension page (popup, options, side panel, any HTML file you ship) loads under a policy you did not write. Chrome's CSP manifest reference gives the MV3 default as script-src 'self'; object-src 'self'; and spells out the consequence: "the extension will only load local scripts and objects from its own packaged resources. WebAssembly will be disabled, and the extension won't run inline Javascript or be able to evaluate strings as executable code."

'self' means a file inside your extension package, requested by URL. A <script> element with a body is not a file, so it never matches.

The usual reflex is to widen the policy, and Manifest V2 habits mislead here. The documented MV2 ways to loosen script-src were a remote URL, 'unsafe-eval', or, for inline code specifically, "supplying the hash of the script in the script-src directive". 'unsafe-inline' was never one of them: MDN files it under invalid examples as "the unsupported keyword". Manifest V3 then removed the hash route as well, and a manifest that tries any of this is rejected, with Chrome throwing "an error like this at install time: 'content_security_policy.extension_pages': Insecure CSP value "'unsafe-eval'" in directive 'script-src'".

What you can still adjust is narrow. Adding 'wasm-unsafe-eval' is the one deliberate loosening on offer, since the default leaves WebAssembly off. Going past the documented minimum, which is what inline script would require, is not.

One narrow exception exists, development-only. MDN's content_security_policy reference: "scripts from localhost can be allowlisted during development for unpacked extensions from Chrome 110 and temporarily loaded extensions from Firefox 147". It buys a host in script-src, not inline script, and does not survive packaging.

One detail that wastes an afternoon: a popup has its own DevTools, and that is where the refusal is logged, at the line of the offending tag. Chrome's debugging tutorial: "You can open the popup's DevTools by inspecting the popup." Right-click the open popup, choose Inspect.

Inline event handlers are inline script too

Externalizing the <script> block is half the job. onclick, onload, onsubmit and friends fall under the same rule: MDN's CSP guide lists "JavaScript in an inline event handler attribute" among what a script-src directive blocks. The attribute is refused when the browser processes it, so the handler is never wired up: the button renders, the click does nothing, and none of your code ran to leave a clue.

<!-- blocked: the handler is never registered -->
<button onclick="save()">Save</button>
// popup.js, loaded with <script src>
document.getElementById("save").addEventListener("click", save);

The same MDN list covers href="javascript:..." links. If you build markup from strings at runtime, audit it for these attributes: a handler written by a template helper is refused like one you typed by hand.

Watch the ordering when you move the code out. An inline block at the bottom of <body> ran after the elements above it existed; an external script in <head> does not, and getElementById returns null. Keep the tag at the end of the body, add defer, or wire up in DOMContentLoaded.

What still breaks after the file is external

The CSP covers more than <script> tags. Three more patterns are refused by it, and a fourth is closed off by the API surface itself:

  • eval() and new Function(). Both evaluate strings as executable code, which is exactly what the default policy withholds, and both throw. This is what breaks a UI library that compiles templates in the browser; many ship a precompiled or CSP-safe build. Check for one before rewriting your popup.
  • A <script src> pointing at a CDN. Different message, same policy, and also a store policy issue. Chrome's security migration guide: "In Manifest V3, all of your extension's logic must be part of the extension package. You can no longer load and execute remotely hosted files according to Chrome Web Store policy." Vendor the file into your bundle.
  • A dev server's injected client. Hot-reload tooling injects an inline bootstrap snippet into the HTML it serves, and that snippet is refused like any other inline script. The client file it loads is the salvageable half: by the localhost exception above, an unpacked extension can allowlist http://localhost:3000 in script-src while you develop.
  • chrome.scripting.executeScript with a string of code. This one is not a CSP refusal: the MV3 API simply has no code property to pass a string to. Use files, or func with args; the docs describe func as "A JavaScript function to inject. This function will be serialized, and then deserialized for injection", and args "must be JSON-serializable", so a closure over an outer variable will not survive the trip.

If your background entry point is the thing failing instead, the message is usually a different one; see why an MV3 service worker throws "Cannot use import statement outside a module".

The same error from a content script means something else

Read the policy the refusal prints, because it names whose rules you broke. Chrome's content scripts documentation gives the isolated world its own CSP, listing 'self', 'wasm-unsafe-eval', 'inline-speculation-rules' and your chrome-extension:// origin. If the refusal instead quotes a policy full of the site's hosts, you have crossed into the page, and the same doc states the rule: "When a content script is injected into the main world, the CSP of the page applies."

So appending <script>const x = 1;</script> to a hardened site's DOM is refused by that site, and no manifest change of yours allows it. Two routes work:

// background.js: run a packaged file in the page's own world
chrome.scripting.executeScript({
  target: { tabId },
  world: "MAIN",
  files: ["page-script.js"],
});

Or keep the logic in the isolated world and talk to the page over window.postMessage. The older trick of appending a <script src="chrome-extension://..."> element still works, but the file must be listed in web_accessible_resources, and a site whose script-src omits your extension origin can still refuse it.

Sandboxed pages are the one exception

Chrome keeps one escape hatch, the single qualifier on the opening rule. A page listed under the sandbox manifest key gets its own policy, documented as sandbox allow-scripts allow-forms allow-popups allow-modals; script-src 'self' 'unsafe-inline' 'unsafe-eval'; child-src 'self';, inline script included: "A sandboxed page is not subject to the Content Security Policy (CSP) used by the rest of the extension (it has its own separate CSP value)."

The price is isolation: "A sandboxed page won't have access to extension APIs, or direct access to non-sandboxed pages (it may communicate with them using postMessage())." No chrome.storage, no chrome.runtime. Right tool for untrusted template code, wrong tool for anything needing browser APIs.

Firefox differences

Firefox enforces the same rule with different defaults. MDN, on script-src and worker-src in extension_pages: "The only permitted values are 'none', 'self', and 'wasm-unsafe-eval'. There is an exception for scripts from localhost during development". No inline script there either.

Two things differ. Firefox's default MV3 policy also carries upgrade-insecure-requests, so plain http: requests from extension pages are upgraded unless you override the key. And the sandbox manifest key arrived late: the Firefox 154 release notes record it as "Adds support for the sandbox manifest key, enabling extensions to designate pages that load with an opaque origin, without direct access to extension APIs." Ship to both stores and a sandboxed page is not a portable assumption on older Firefox builds.

The practical rule

Ship every line of script as a file in the package, wire behaviour with addEventListener, and treat any library that needs eval as a build-time problem rather than a runtime one. The same rule decides which dependencies fit an extension at all: anything that pulls a snippet from a CDN or compiles a string is out.

That constraint is why the Moderok SDK is a plain module you import from your own bundle, with no remote code, no eval, and no use of window, document, or localStorage, so it runs under the default policy without a content_security_policy key at all. Setup is the storage permission and a call to Moderok.init(): see the manifest and permissions guide.