Running User-Supplied Code with the userScripts API
Build a userscript manager in MV3 with chrome.userScripts: the userScripts permission, the user toggle requirement, registering code strings, USER_SCRIPT world isolation, configureWorld CSP and messaging, and Firefox support.
Table of Contents
Userscript managers, site-customisation tools and automation helpers exist to run code the user wrote or chose. MV3 bans executing code that is not in the package — eval, new Function, code strings in executeScript — which seemed to make these extensions impossible. The chrome.userScripts API is the sanctioned exception: it runs code strings supplied at runtime, in an isolated world of their own, under conditions that make the user’s consent explicit. Using it correctly means understanding the opt-in the browser requires, the world model, and what reviewers expect. This guide covers all three. It belongs to scripting API and dynamic injection.
Why userScripts is a separate API
MV3’s remote-code ban protects users from extensions that change behaviour after review. Userscripts are different in kind: the code comes from the user, not the developer, and running it is the product’s whole purpose. Chrome reconciles the two by putting user scripts behind an explicit user action: the extension must declare the userScripts permission, and the user must turn on a toggle — “Developer mode” on the extensions page in earlier versions, a per-extension “Allow User Scripts” switch in the extension’s details in newer ones — before the API becomes available. Scripts run in a dedicated USER_SCRIPT world, isolated from both the page and your extension’s content scripts, with a configurable CSP and optional messaging to the extension. The store still reviews the extension, and expects the code to be genuinely user-supplied.
Step-by-step: a minimal userscript manager
1. Declare the permission and detect availability
1{
2 "permissions": ["userScripts", "storage"],
3 "host_permissions": ["<all_urls>"], // scripts can only run where you have access
4 "minimum_chrome_version": "120"
5}
1// sw.js
2export function userScriptsAvailable() {
3 try {
4 chrome.userScripts.getScripts(); // throws if the user has not enabled user scripts
5 return true;
6 } catch {
7 return false;
8 }
9}
Execution context: the manifest and the service worker. Accessing chrome.userScripts methods throws when the user has not turned on the required toggle, so a try-call is the reliable check. Show onboarding that explains the toggle — with the exact name used by the user’s Chrome version — rather than failing silently. Host permissions still apply: user scripts run only on sites the extension can access.
2. Configure the user script world
1await chrome.userScripts.configureWorld({
2 csp: "script-src 'self' 'unsafe-eval'", // policy applied to code in the USER_SCRIPT world
3 messaging: true, // allow chrome.runtime.sendMessage from user scripts
4});
Execution context: the service worker, before registering scripts. The world’s CSP governs what user code itself can do — whether it can eval, load remote scripts — independently of the page’s CSP and your extension’s. messaging: true exposes chrome.runtime.sendMessage and connect inside user scripts, which arrive in your worker on onUserScriptMessage (separate from onMessage), letting you offer a GM-style API. Leave messaging off if you do not need it.
3. Register scripts from stored user data
1export async function syncUserScripts() {
2 const { scripts = [] } = await chrome.storage.local.get("scripts");
3 const wanted = scripts.filter((s) => s.enabled).map((s) => ({
4 id: `us_${s.id}`,
5 matches: s.matches,
6 excludeMatches: s.excludeMatches ?? [],
7 js: [{ code: s.code }],
8 runAt: s.runAt ?? "document_idle",
9 world: s.world === "MAIN" ? "MAIN" : "USER_SCRIPT",
10 allFrames: Boolean(s.allFrames),
11 }));
12 const existing = await chrome.userScripts.getScripts();
13 if (existing.length) await chrome.userScripts.unregister({ ids: existing.map((s) => s.id) });
14 if (wanted.length) await chrome.userScripts.register(wanted);
15}
Execution context: the service worker. User scripts persist across browser restarts once registered; re-syncing from storage on install, update and every edit keeps the registered set identical to what the user sees in your manager. js accepts { code } objects for user-supplied strings and { file } for packaged helpers. world: "MAIN" runs the script in the page’s world, needed for scripts that patch page globals — offer it as an explicit per-script option.
4. Validate metadata, not code
1function validateScript(s) {
2 if (typeof s.code !== "string" || s.code.length > 1_000_000) return "code too large";
3 if (!Array.isArray(s.matches) || s.matches.length === 0) return "at least one match pattern";
4 for (const m of s.matches) if (!/^(https?|\*|file):\/\/[^/]+\/.*$|^<all_urls>$/.test(m)) return `invalid match: ${m}`;
5 return null;
6}
Execution context: the editor page or worker, before saving. The extension cannot meaningfully “validate” user code — that is the user’s responsibility — but it must validate everything the browser will interpret: match patterns, run timing, world, size. An invalid pattern fails the whole register call, taking every script down with it.
5. Offer a small API through messaging
1chrome.runtime.onUserScriptMessage.addListener((msg, sender, sendResponse) => {
2 if (msg?.type === "GM_setValue") {
3 const key = `gm:${sender.userScriptId ?? "unknown"}:${String(msg.key).slice(0, 200)}`;
4 chrome.storage.local.set({ [key]: msg.value }).then(() => sendResponse({ ok: true }));
5 return true;
6 }
7});
Execution context: the service worker. Messages from user scripts arrive on their own event, so they cannot be confused with your content scripts’ messages, and they should be treated as untrusted input — user scripts run on arbitrary pages. Namespace stored values per script. Keep the API small; every privileged operation you expose is something any user script — including a malicious one a user was tricked into installing — can call.
6. Run a script once on demand
1// Chrome 135+: execute a user script immediately in a tab
2if (typeof chrome.userScripts.execute === "function") {
3 await chrome.userScripts.execute({ target: { tabId }, js: [{ code: userCode }], world: "USER_SCRIPT" });
4}
Execution context: the service worker, for “Run now” buttons. Newer Chrome versions add one-off execution; on older ones, register a temporary script matching the current URL and reload, or tell the user the script will run on the next load.
7. Be explicit with users and reviewers
Explain in onboarding that the extension runs code the user provides, show which scripts are active on the current page in the popup, and make enabling a script a deliberate action. In the store listing, state that the extension’s single purpose is managing user scripts. Never ship scripts of your own through this API — packaged features belong in content scripts, and distributing your own code as “user scripts” is a policy violation.
Common mistakes
- Assuming the API is available. It throws until the user enables the toggle.
- Registering with one bad pattern. The whole batch fails; validate each script.
- Using
onMessagefor user script messages. They arrive ononUserScriptMessage. - Large privileged API surfaces. Every exposed operation is available to any script the user installs.
- Shipping developer code as user scripts. Reviewers treat it as remote code.
Cross-browser variation
- Chrome / Edge:
userScriptsfrom Chrome 120 behind a user toggle;configureWorld,onUserScriptMessage, andexecutein newer versions. - Firefox: MV3
userScriptssupport arrived in recent versions as an optional permission the user grants at runtime; the API shape is close to Chrome’s — feature-detect methods. - Safari: no
userScriptsAPI; userscript managers on Safari ship as separate apps with their own mechanisms.
Verification
- With the toggle off, confirm the manager shows onboarding instead of throwing.
- Turn it on, save a script matching a test site, and confirm it runs on load in the
USER_SCRIPTworld (it cannot see your content script’s globals). - Enable messaging, call your GM-style API from the script, and confirm namespaced storage.
- Save a script with an invalid match pattern and confirm validation blocks it without affecting others.
FAQ
Can user scripts use eval?
Only if the world’s CSP permits it via configureWorld. That decision affects only user script code, not your extension pages.
Do user scripts survive extension updates?
Registered scripts persist, but re-sync from storage on update to be safe.
Can user scripts run in incognito?
If the extension is allowed in incognito, yes — consider making that a per-script option.
Related
- Replacing remotely hosted code — why this API exists.
- Registering content scripts at runtime — the packaged-code equivalent.
- Treating content script messages as untrusted — the same stance for user script messages.
- Scripting API and dynamic injection — the parent topic.