Passing Chrome Web Store Review
Fix the most common Chrome Web Store rejection causes — excessive permissions, remote code, unclear purpose and missing privacy disclosures — and navigate the review workflow.
Fix the most common Chrome Web Store rejection causes — excessive permissions, remote code, unclear purpose and missing privacy disclosures — and navigate the review workflow.
Submitting a Manifest V3 extension only to receive a rejection email a few days later is a frustrating but very common experience. The Chrome Web Store runs every submission through an automated policy scan followed by a human review queue, and a single violation in any category — permissions, code practices, listing content, or privacy — is enough to block publication. This page walks through the most frequent rejection causes in MV3 extensions and the concrete steps to fix each one before you resubmit. For broader context on the permissions model, see the store submission and permissions compliance reference.
The review process exists to protect users from extensions that over-reach, deceive, or execute untrusted code. MV3 tightened the technical floor — background pages are gone, remote code execution is blocked at the platform level — but the policy layer applies additional human judgment on top. Rejections fall into two buckets: technical violations that the automated scanner flags (eval usage, suspicious network calls, declared-but-unused APIs) and policy violations that a human reviewer flags (vague purpose, broken privacy policy URL, misleading screenshots). Understanding which bucket your rejection falls into determines whether a quick code fix or a listing rewrite is the right response.
The most common automatic rejection is requesting broader permissions than the extension demonstrably needs. Reviewers look for two patterns: host permissions scoped to <all_urls> or *://*/* when the extension only ever talks to one domain, and the tabs permission when activeTab would suffice.
Before:
1{
2 "permissions": ["tabs", "storage"],
3 "host_permissions": ["<all_urls>"]
4}
Execution context: this manifest is parsed by the browser at install time and evaluated by the CWS policy scanner at submission time — both enforce the declared scope.
After:
1{
2 "permissions": ["activeTab", "storage"],
3 "host_permissions": ["https://api.example.com/*"],
4 "optional_permissions": ["tabs"],
5 "optional_host_permissions": ["<all_urls>"]
6}
Execution context: activeTab grants temporary access to the current tab only when the user invokes the extension; optional_permissions lets you request broader access at runtime only when a specific feature needs it. See requesting optional permissions at runtime for the full pattern.
Also grep your source for every permission you have declared and confirm it maps to at least one API call:
1# List declared permissions, then search for usages
2grep -r "chrome\.tabs\." src/
3grep -r "chrome\.history\." src/
4grep -r "chrome\.bookmarks\." src/
Execution context: run this in your local shell before packaging; any declared permission that produces zero grep hits is an orphaned entry the reviewer will flag.
Remove every orphaned permission from the manifest before resubmitting.
MV3 disallows eval(), new Function(string), and externally-loaded scripts. The automated scanner searches for these patterns in your bundled output, not just your source. A common hidden source is a third-party library that internally uses eval for feature detection.
Replace dynamic evaluation with a bundled function reference:
1// Before — will trigger rejection
2const fn = new Function("return " + userExpression)();
3
4// After — bundle the logic, pass data not code
5import { evaluateExpression } from "./expression-engine.js";
6const result = evaluateExpression(userExpression);
Execution context: both the before and after run in a service worker or content script context; only the after form is permitted under MV3’s Content Security Policy, which blocks unsafe-eval in extension pages.
For chrome.scripting.executeScript, never pass a string. Always reference a function defined in your bundle:
1// Before — rejected
2chrome.scripting.executeScript({
3 target: { tabId },
4 code: "document.body.style.background = 'red';"
5});
6
7// After — permitted
8function paintBackground() {
9 document.body.style.background = "red";
10}
11
12chrome.scripting.executeScript({
13 target: { tabId },
14 func: paintBackground
15});
Execution context: func must reference a function that exists in the service worker’s own scope at the time of the call — it is serialized and injected, so it cannot close over variables from the outer scope.
Audit your node_modules for eval usage before bundling:
1npx is-eval-free ./dist/bundle.js
2# or manually:
3grep -n "eval\|new Function" dist/bundle.js
Execution context: run against the final built artifact in your local shell, not your source files, because bundlers sometimes inline eval from dependencies.
The Chrome Web Store requires a filled-out data disclosure form for every extension that collects, uses, or shares user data. A broken privacy policy URL or a policy that is too generic (“we may collect data”) causes a human reviewer to reject the listing.
Log in to the Chrome Web Store Developer Dashboard, open your extension, navigate to Privacy practices, and fill out every section that applies:
chrome.history or read the URLs of tabs the user visits.For each checked category, you must state whether data is shared with third parties and link to your privacy policy. Host the policy at a stable URL on a domain you control — a Google Doc or GitHub Gist is not sufficient. The policy must describe what data is collected, why, how long it is retained, and how users can request deletion.
Execution context: this is a form in the developer dashboard, not code; the URL you provide must return a 200 response and render readable policy text at review time.
Packaging mistakes cause rejections that look like content policy violations but are actually submission format issues.
1# Use web-ext to produce a clean zip
2npx web-ext build \
3 --source-dir ./dist \
4 --artifacts-dir ./artifacts \
5 --ignore-files "node_modules/**" "src/**" "*.map" ".env*"
Execution context: run in your local shell from the project root; web-ext build validates the manifest before zipping, which catches missing required fields before the CWS scanner does.
If you prefer a manual zip:
1cd dist && zip -r ../extension.zip . -x "*.map" -x "node_modules/*"
Execution context: run in the directory that contains only the production build output — never zip the project root, which would include source files, node_modules, and credentials.
Key packaging rules:
node_modules/ — bundle all dependencies into your output files..env files, API keys, or test fixtures.Before submitting, run through this checklist to confirm each fix is in place:
"permissions" or "host_permissions" is broader than your code requires.grep -rn "eval\|new Function\|document\.write" dist/ and confirm zero hits.chrome://extensions with Developer Mode enabled, open the service worker DevTools, and exercise every feature — confirm no CSP errors appear in the console.web-ext lint --source-dir ./dist and resolve any reported issues.Expected output from a clean lint run:
Validation Summary:
0 errors
0 warnings
0 notices
Execution context: web-ext lint runs in your local shell and does not require a browser; it checks the manifest schema, file references, and common policy patterns.
Re-submissions typically enter the same review queue as new submissions. Expect one to three business days for automated review and up to a week for human review. Repeated resubmissions for the same violation can increase review time, so fix all identified issues before resubmitting rather than addressing them one at a time.
Yes. Use the appeal form linked in the rejection email or navigate to the CWS developer support page. Provide a clear explanation of why the flagged item does not violate policy and, if applicable, a code snippet or video demonstrating the intended behavior. Appeals are reviewed by a separate team from the initial reviewers.
The CWS single-purpose policy means each distinct user-facing feature should follow logically from one clearly stated goal in your store listing. An extension that blocks ads and also rewrites the appearance of every page and also manages bookmarks is likely to be flagged. If your extension genuinely does multiple unrelated things, split it into separate extensions. If the features are related, tighten the store description so the connection is obvious.
You still need to complete the data disclosure form and answer “no” to each data category. If you genuinely collect nothing, you may not need a hosted policy document, but the form must be submitted and accurate. Leaving it blank causes an automatic rejection.
Submit MV3 extensions to Chrome Web Store, Firefox AMO and Safari, justify permissions under least-privilege, meet privacy disclosure requirements and avoid common rejection causes.
Inject JavaScript and CSS into web pages with MV3 content scripts — isolated worlds, declarative vs programmatic injection, run_at timing, MAIN world risks, and Shadow DOM UI patterns.
Build MV3-compliant browser extension popups: ephemeral lifecycle, storage hydration, service worker messaging, and cross-browser constraints explained.
Register options_ui or options_page in Manifest V3, persist user preferences through chrome.storage.sync, and keep every extension context in sync via onChanged.