Back to Blog

Least Privilege Implementation & Chrome Web Store Review Checklist

July 202615 min read

The Permission Model in MV3

Manifest V3 overhauled how extensions declare and consume capabilities. Every permission you request is a trust negotiation with the user — and with the Chrome Web Store review team. The three categories are not interchangeable:

permissions

Core API capabilities. Chrome shows these explicitly on the install dialog. Each one adds friction:

{
  "permissions": [
    "activeTab",
    "storage",
    "scripting",
    "contextMenus",
    "downloads"
  ]
}

When a user sees a list of 12 permissions, they hesitate. When they see 3, they install. This is measurable — my extensions with 4 or fewer permissions have 20-35% higher install-to-impression ratios than comparable extensions requesting 8+.

host_permissions

URL access patterns. These appear under a separate "This extension can read and change your data on..." section in the install dialog. In MV3, host permissions trigger a security badge warning for broad patterns:

{
  "host_permissions": [
    "https://specific-site.com/*"
  ]
}

Avoid "<all_urls>" unless your extension is a universal tool (ad blocker, password manager). Even then, expect longer review times.

optional_permissions

Not shown at install time. Requested at runtime via chrome.permissions.request() inside a user gesture handler. This is the gold standard for permissions you only need sometimes:

// Request at runtime — only when user explicitly triggers the feature
async function requestDownloadsPermission() {
  const granted = await chrome.permissions.request({
    permissions: ['downloads']
  });
  if (!granted) {
    showPermissionDeniedUI();
    return false;
  }
  return true;
}
Permission cost model: Each permission you add increases install friction exponentially, not linearly. A 5-permission extension feels "heavy" to users. Move anything non-essential to optional_permissions.

activeTab: The Golden Permission

If you take one thing from this article, let it be this: activeTab is the single most important permission for passing review and minimizing user friction. It gives you:

Replacing <all_urls> with activeTab

Before (guaranteed to trigger review flags):

{
  "permissions": ["tabs"],
  "host_permissions": ["<all_urls>"]
}

After (clean, minimal, passes review):

{
  "permissions": ["activeTab", "scripting"]
}

Complete Code Flow

Here is the full flow: user clicks the extension icon, the popup opens, they click a button, and the extension injects a script into the active tab:

// popup.js — runs inside the extension popup
document.getElementById('extractBtn').addEventListener('click', async () => {
  // activeTab is already granted because the user opened the popup (user gesture)
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });

  try {
    const results = await chrome.scripting.executeScript({
      target: { tabId: tab.id },
      func: extractPageData,
    });

    const data = results[0].result;
    displayResults(data);
  } catch (err) {
    if (err.message.includes('Cannot access')) {
      showError('This page does not allow extension access (chrome://, edge://, etc.)');
    } else {
      showError(`Injection failed: ${err.message}`);
    }
  }
});

// This function runs IN the page context — it has no access to chrome.* APIs
function extractPageData() {
  const headings = Array.from(document.querySelectorAll('h1, h2, h3'))
    .map(h => ({ level: h.tagName, text: h.textContent.trim() }));
  const links = Array.from(document.querySelectorAll('a[href]'))
    .map(a => ({ text: a.textContent.trim(), url: a.href }));
  return { headings, links, title: document.title, url: location.href };
}

This pattern needs exactly two permissions: activeTab and scripting. No tabs, no host permissions, no warnings at install time. It is the cleanest path through review.

Dynamic Script Injection Best Practices

chrome.scripting.executeScript with activeTab

The chrome.scripting API is the MV3 replacement for the old chrome.tabs.executeScript. It's more explicit about what you're injecting and where:

// Inject a function — the most common pattern
await chrome.scripting.executeScript({
  target: { tabId: tab.id },
  func: myFunction,
  args: [param1, param2]  // arguments passed to myFunction
});

// Inject a file — for complex scripts
await chrome.scripting.executeScript({
  target: { tabId: tab.id },
  files: ['content/extractor.js']
});

Injecting CSS Dynamically

You can also inject stylesheets on demand, useful for highlighting elements or overlaying UI:

await chrome.scripting.insertCSS({
  target: { tabId: tab.id },
  css: `
    .extension-highlight {
      outline: 3px solid #4CAF50;
      outline-offset: 2px;
      background: rgba(76, 175, 80, 0.1);
    }
    .extension-overlay {
      position: fixed;
      bottom: 20px;
      right: 20px;
      z-index: 2147483647;
      padding: 12px 20px;
      background: #1a1a2e;
      color: #fff;
      border-radius: 8px;
      font-size: 14px;
      box-shadow: 0 4px 12px rgba(0,0,0,0.3);
    }
  `
});

// Remove injected CSS when done
await chrome.scripting.removeCSS({
  target: { tabId: tab.id },
  css: `.extension-highlight { ... }`
});

Checking If Already Injected

Double-injection is a common bug that causes duplicate event listeners and memory leaks. Guard against it:

// Use a world-scoped flag to check injection state
async function safeInject(tabId) {
  try {
    const [{ result: alreadyInjected }] = await chrome.scripting.executeScript({
      target: { tabId },
      func: () => {
        if (window.__myExtensionInjected) return true;
        window.__myExtensionInjected = true;
        return false;
      },
      world: 'MAIN'  // runs in the page's JS context
    });

    if (alreadyInjected) {
      console.log('Content script already active on tab', tabId);
      return false;
    }

    await chrome.scripting.executeScript({
      target: { tabId },
      files: ['content/main.js']
    });
    return true;
  } catch (err) {
    console.error('Injection check failed:', err);
    return false;
  }
}

Error Handling When Tab Doesn't Allow Injection

Not every tab is injectable. Chrome internal pages, other extension pages, and certain restricted origins will reject injection. Always handle this gracefully:

async function injectWithFallback(tabId, func, args) {
  // First, check if the tab is injectable
  const tab = await chrome.tabs.get(tabId);
  const blockedPrefixes = ['chrome://', 'edge://', 'chrome-extension://',
    'about:', 'chrome.google.com/webstore'];

  if (blockedPrefixes.some(p => tab.url?.startsWith(p))) {
    return { error: 'blocked', message: 'Cannot inject into this page type' };
  }

  try {
    const results = await chrome.scripting.executeScript({
      target: { tabId },
      func,
      args
    });
    return { success: true, result: results[0].result };
  } catch (err) {
    if (err.message.includes('Cannot access contents of url')) {
      return { error: 'restricted', message: 'Page restricted by Chrome policy' };
    }
    if (err.message.includes('No tab with id')) {
      return { error: 'closed', message: 'Tab was closed during operation' };
    }
    return { error: 'unknown', message: err.message };
  }
}

High-Risk Permissions & Store Descriptions

Certain permissions trigger extended review. Here's how to handle each one:

cookies

Grants access to the chrome.cookies API for reading/writing cookies across all sites. Reviewers will ask: "why does this need cookie access?" Only request it if you're building a session manager, developer tool, or cookie editor. Always pair with specific host_permissions:

{
  "permissions": ["cookies"],
  "host_permissions": ["https://specific-app.com/*"]
}

In your store description, include: "This extension accesses cookies only for [specific purpose] on [specific domains]. It does not collect or transmit cookie data."

debugger

The debugger permission attaches to the Chrome DevTools Protocol. It is almost guaranteed to trigger rejection unless you have a very clear developer-tool use case (performance profiler, network inspector, accessibility auditor). If you need it:

{
  "permissions": ["debugger"]
}

Store description must include: "This extension uses the debugger API exclusively for [specific dev tool purpose]. It attaches only when the user explicitly activates the feature and detaches immediately after."

offscreen

MV3's offscreen documents let you run DOM operations outside of a visible tab. Common legitimate uses: audio playback, clipboard access, document parsing. Always document the exact use case:

// background.js — create offscreen document for clipboard operations
async function ensureOffscreen() {
  const existingContexts = await chrome.runtime.getContexts({
    contextTypes: ['OFFSCREEN_DOCUMENT']
  });
  if (existingContexts.length > 0) return;

  await chrome.offscreen.createDocument({
    url: 'offscreen.html',
    reasons: ['CLIPBOARD'],
    justification: 'Access clipboard content for user-initiated copy operation'
  });
}

Store description: "This extension creates an offscreen document solely for [clipboard/audio/parsing] operations that cannot be performed in the service worker."

webRequest

In MV3, webRequest is read-only (no blocking). For blocking, you need declarativeNetRequest. If you request webRequest, reviewers will check that you're not trying to do MV2-style blocking:

// MV3: use declarativeNetRequest for blocking
{
  "permissions": ["declarativeNetRequest"],
  "host_permissions": ["https://api.example.com/*"],
  "declarative_net_request": {
    "rule_resources": [{
      "id": "ruleset_1",
      "enabled": true,
      "path": "rules.json"
    }]
  }
}

If you truly need webRequest (for monitoring/logging, not blocking): "This extension uses webRequest for read-only network monitoring. It does not modify, block, or redirect any requests."

Template Store Description

Here's a template that covers the permission justification section most reviewers look for:

Permissions explanation (include in your store listing):
This extension requests only the minimum permissions needed:
- activeTab: Grants temporary access to the current tab only when you click the extension button. No background access to your browsing.
- storage: Saves your preferences and settings locally. No data leaves your device.
- scripting: Injects the content script into the active tab to perform [specific feature]. Only runs when you activate it.

This extension does NOT collect personal data, does NOT track your browsing, and does NOT communicate with external servers.

Top 12 Rejection Reasons & Fixes

Based on patterns from hundreds of review submissions and rejections:

1. Excessive Permissions

Problem: Requesting permissions your extension doesn't visibly use in its core features.

Fix: Audit your manifest. Remove every permission and add them back one at a time, only when a feature breaks:

// Before: over-broad
{
  "permissions": ["tabs", "storage", "scripting", "downloads",
    "notifications", "bookmarks", "history", "cookies"],
  "host_permissions": ["<all_urls>"]
}

// After: minimal
{
  "permissions": ["activeTab", "storage", "scripting"],
  "optional_permissions": ["downloads", "notifications"]
}

2. Missing Privacy Policy

Problem: Any extension that requests host_permissions, uses remote APIs, or handles user data must have a privacy policy URL in the Developer Dashboard.

Fix: Create a privacy policy page (even a simple one) and link it in your store listing. Include: what data you collect, how it's stored, whether it's shared, and how users can request deletion.

3. Remote Code Execution

Problem: MV3 forbids loading code from remote URLs. Even eval() on a string fetched from a server will be rejected.

Fix: Bundle all code in the extension package. For dynamic behavior, use configuration objects, not executable code:

// WRONG — remote code execution
const response = await fetch('https://myserver.com/plugin.js');
const code = await response.text();
eval(code);

// RIGHT — remote configuration, local code
const config = await fetch('https://myserver.com/config.json').then(r => r.json());
const handler = handlerRegistry[config.action]; // local function reference
handler(config.params);

4. Obfuscated Code

Problem: Minified or obfuscated code that reviewers cannot read. This includes variable mangling beyond standard minification, string encoding, and control flow flattening.

Fix: Use standard terser/webpack minification only. Remove any custom obfuscation. If you use a build tool, ensure the output is human-readable:

// webpack.config.js — production build, no obfuscation
module.exports = {
  mode: 'production',
  optimization: {
    minimize: true,  // standard minification is fine
    // Do NOT use javascript-obfuscator or similar plugins
  }
};

5. Misleading Functionality

Problem: The extension does something different from what the store description promises.

Fix: Your description, screenshots, and actual functionality must align. If your extension converts images, don't also silently collect analytics. Describe exactly what each feature does.

6. Data Collection Without Disclosure

Problem: Sending data to external servers without informing the user and without a privacy policy.

Fix: If you use analytics (Google Analytics, Mixpanel, custom), disclose it. Better yet, use local-only analytics or no analytics at all:

// Use chrome.storage for local analytics — no external calls
async function trackEvent(name, data) {
  const { events = [] } = await chrome.storage.local.get('events');
  events.push({ name, data, timestamp: Date.now() });
  // Keep only last 1000 events
  if (events.length > 1000) events.splice(0, events.length - 1000);
  await chrome.storage.local.set({ events });
}

7. Unauthorized API Usage

Problem: Using APIs not declared in permissions (Chrome will block this at runtime, but it also flags during review).

Fix: Test every feature with only the permissions declared in your manifest. Remove any code that calls APIs you haven't declared.

8. Inadequate Description

Problem: One-line description with no detail about what the extension does or why it needs its permissions.

Fix: Write a clear, detailed description. Include: what the extension does, how the user activates features, what permissions are needed and why, and what data (if any) is collected.

9. Broken Installation or Crash on Load

Problem: Extension throws errors immediately after installation or crashes on first use.

Fix: Test on a clean Chrome profile. Install, click the icon, and verify basic functionality works without any additional setup. Handle missing configuration gracefully:

// popup.js — handle first-run gracefully
document.addEventListener('DOMContentLoaded', async () => {
  const { config } = await chrome.storage.local.get('config');
  if (!config) {
    showSetupWizard();  // Guide the user through initial configuration
    return;
  }
  showMainUI(config);
});

10. Duplicate or Confusing Functionality

Problem: Your extension duplicates Chrome's built-in features or another well-known extension without adding value.

Fix: Clearly differentiate your extension. Explain in the description what makes it unique and why the built-in alternative is insufficient.

11. Untranslated or Machine-Translated Content

Problem: Store listing in English but the extension UI is in another language (or vice versa), or obvious machine translation with errors.

Fix: If your extension supports multiple languages, use _locales and ensure your store listing matches the primary language. Don't submit in a language you can't maintain.

12. Intellectual Property Violations

Problem: Using trademarks, logos, or copyrighted content without permission.

Fix: Don't use "Chrome" in your extension name. Don't include third-party logos in your icons. If you integrate with a service, use "for [Service]" not "[Service] [Feature]".

Multi-Browser Compatibility

If you target Chrome, Edge, and Firefox, the permission model has subtle but important differences:

Chrome vs Edge

Edge is Chromium-based and supports nearly identical permissions. The key difference: Edge uses edge:// URLs instead of chrome://. Check for both:

const blockedPrefixes = [
  'chrome://', 'edge://', 'chrome-extension://',
  'moz-extension://', 'about:', 'chrome.google.com/webstore',
  'microsoftedge.microsoftaddons.com'
];

function isInjectable(url) {
  return url && !blockedPrefixes.some(p => url.startsWith(p));
}

Firefox Differences

Firefox uses Manifest V3 but with significant differences in permissions:

// Firefox requires browser_specific_settings
{
  "manifest_version": 3,
  "name": "My Extension",
  "permissions": ["activeTab", "storage", "scripting"],
  "browser_specific_settings": {
    "gecko": {
      "id": "my-extension@example.com",
      "strict_min_version": "109.0"
    }
  }
}

Key Firefox differences:

Polyfill Strategy

Use the webextension-polyfill library to normalize the chrome.* vs browser.* API surface:

// Install: npm install webextension-polyfill
import browser from 'webextension-polyfill';

// Use browser.* consistently — works on Chrome, Edge, and Firefox
const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
const results = await browser.scripting.executeScript({
  target: { tabId: tab.id },
  func: extractData
});

// Storage works identically
await browser.storage.local.set({ key: 'value' });
const { key } = await browser.storage.local.get('key');

For manifest differences, maintain separate manifest files and use a build script to merge them:

// build.js
const baseManifest = require('./manifest.base.json');
const chromeManifest = { ...baseManifest };
const firefoxManifest = {
  ...baseManifest,
  browser_specific_settings: {
    gecko: { id: 'myext@example.com', strict_min_version: '109.0' }
  }
};

// Chrome uses storage.session without declaring "storage" permission
// Firefox requires it
firefoxManifest.permissions.push('storage');

fs.writeFileSync('./dist/chrome/manifest.json', JSON.stringify(chromeManifest, null, 2));
fs.writeFileSync('./dist/firefox/manifest.json', JSON.stringify(firefoxManifest, null, 2));

Production Checklist

  1. Manifest audit — Remove every permission not used by a visible feature. Test each removal.
  2. Replace broad host_permissions — Use activeTab + scripting instead of <all_urls>.
  3. Move non-essential permissions to optional_permissions — Request at runtime with user gesture.
  4. Write a privacy policy — Link it in the Developer Dashboard store listing field.
  5. Add a permissions justification section — Include in your store description (see template above).
  6. Verify no remote code execution — No eval(), no new Function() on fetched strings, no remote script tags.
  7. Remove code obfuscation — Standard minification only. Reviewers must be able to read your source.
  8. Test on a clean profile — Install from the built ZIP. Verify first-run experience without any prior state.
  9. Check all error paths — Handle blocked tabs, closed tabs, denied permissions, missing config.
  10. Verify data disclosure — If you send any data externally, disclose it and link the privacy policy.
  11. Match description to functionality — Every feature in the description must exist. Every significant feature should be described.
  12. Include screenshot and video — Show the extension in action. Reviewers check these.
  13. Test multi-browser if claiming support — Build and test on Chrome, Edge, and Firefox separately.
  14. Use _locales for i18n — If your store listing is in English, ensure the default locale is en.
  15. Run chrome.runtime.getManifest() — Verify the loaded manifest matches what you expect. No stale builds.

References