Requesting Optional Permissions at Runtime

Use chrome.permissions.request and optional_host_permissions in MV3 to gate features behind user consent, handle the user-gesture requirement, and degrade gracefully when denied.

Published June 19, 2026 Updated June 19, 2026 8 min read
Table of Contents

You need to unlock a feature — reading bookmarks, accessing a specific site’s DOM, or querying open tabs — but only for users who explicitly opt in. Calling chrome.permissions.request() from the wrong place causes it to silently fail or throw an error, leaving the feature permanently broken with no feedback to the user. This guide covers the complete runtime permissions flow for Manifest V3, from manifest declaration through graceful degradation. See the store submission and permissions compliance section for how optional permissions affect Chrome Web Store review.

Why the User-Gesture Requirement Exists

MV3’s execution model separates UI contexts from background execution. Service workers run in a headless environment with no window, no tab focus, and no user interaction — the browser has no way to display a permission prompt in that context. The chrome.permissions.request() API requires a foreground UI context (popup, options page, or injected content script) with an active user gesture, meaning it must be triggered directly inside an event handler for a click, keypress, or similar input event. Calling it from a chrome.runtime.onMessage listener in the service worker, from a setTimeout, or on extension startup will either return false silently or throw "This function must be called during a user gesture" depending on Chrome version. This is the single most common runtime permissions mistake in MV3 extensions.

Step-by-Step Solution

1. Declare optional permissions in the manifest

List API permissions in optional_permissions and URL patterns in optional_host_permissions. Neither array triggers an install-time prompt — they only gate what your extension is allowed to request later.

1{
2  "manifest_version": 3,
3  "name": "My Extension",
4  "permissions": ["storage"],
5  "optional_permissions": ["tabs", "bookmarks"],
6  "optional_host_permissions": ["https://example.com/*", "https://*.github.com/*"]
7}

Execution context: manifest.json — parsed by the browser at install time, not in any runtime context.

Any permission absent from optional_permissions or optional_host_permissions cannot be requested at runtime. Chrome will reject the chrome.permissions.request() call with an error if you attempt to request an undeclared permission.

2. Check whether the permission is already granted

Before showing a consent prompt, verify the current state. This avoids prompting users who already granted permission in a previous session.

 1// popup.ts or options.ts
 2async function hasBookmarksPermission(): Promise<boolean> {
 3  return chrome.permissions.contains({
 4    permissions: ["bookmarks"],
 5  });
 6}
 7
 8async function hasGithubHostPermission(): Promise<boolean> {
 9  return chrome.permissions.contains({
10    origins: ["https://*.github.com/*"],
11  });
12}

Execution context: popup or options page script, called on page load to set initial UI state.

3. Request permissions inside a user-gesture handler

Wire the request directly into a button’s click handler. Do not await anything before the chrome.permissions.request() call — the gesture context is consumed by the first await that yields to the event loop.

 1// popup.ts
 2const enableButton = document.getElementById("enable-bookmarks") as HTMLButtonElement;
 3
 4enableButton.addEventListener("click", async () => {
 5  // call request() synchronously within the click handler — no awaits before this
 6  const granted = await chrome.permissions.request({
 7    permissions: ["bookmarks"],
 8  });
 9
10  if (granted) {
11    showBookmarksFeature();
12  } else {
13    showPermissionDeniedMessage();
14  }
15});

Execution context: popup page script, executing synchronously within a user click event.

To request a host permission alongside an API permission in the same prompt, pass both in one call:

1enableButton.addEventListener("click", async () => {
2  const granted = await chrome.permissions.request({
3    permissions: ["tabs"],
4    origins: ["https://*.github.com/*"],
5  });
6
7  updateFeatureState(granted);
8});

Execution context: popup or options page script, inside a direct user-gesture event handler.

4. Handle the Promise result and update UI

chrome.permissions.request() resolves to true if the user clicked “Allow” and false if they dismissed or denied the prompt. Treat both as valid states.

 1// popup.ts
 2async function updateFeatureState(granted: boolean): Promise<void> {
 3  const featureSection = document.getElementById("github-feature")!;
 4  const enableButton = document.getElementById("enable-github")!;
 5
 6  if (granted) {
 7    featureSection.classList.remove("hidden");
 8    enableButton.classList.add("hidden");
 9    await chrome.storage.local.set({ githubPermissionGranted: true });
10  } else {
11    featureSection.classList.add("hidden");
12    enableButton.classList.remove("hidden");
13    // do not store false — absence of the key means "not granted"
14  }
15}

Execution context: popup page script, called after resolving the permissions.request() Promise.

5. Degrade gracefully when permission is not granted

On popup open, read the current permission state and hide gated features rather than showing them in a broken state.

 1// popup.ts — runs on DOMContentLoaded
 2async function initPopup(): Promise<void> {
 3  const hasGithub = await hasGithubHostPermission();
 4  const featureSection = document.getElementById("github-feature")!;
 5  const enableButton = document.getElementById("enable-github")!;
 6
 7  if (hasGithub) {
 8    featureSection.classList.remove("hidden");
 9    enableButton.classList.add("hidden");
10  } else {
11    featureSection.classList.add("hidden");
12    enableButton.classList.remove("hidden");
13    enableButton.textContent = "Enable GitHub Integration";
14  }
15}
16
17document.addEventListener("DOMContentLoaded", initPopup);

Execution context: popup page script, on initial render before any user interaction.

6. Re-check permissions on every service worker wake

Service workers are terminated and restarted by the browser. They cannot assume that any state — including granted permissions — persists between activations. On each startup, re-read permission status from chrome.permissions.contains() and cache the result in chrome.storage.local for fast access during the same session.

 1// service-worker.ts
 2async function syncPermissionState(): Promise<void> {
 3  const [hasBookmarks, hasGithub] = await Promise.all([
 4    chrome.permissions.contains({ permissions: ["bookmarks"] }),
 5    chrome.permissions.contains({ origins: ["https://*.github.com/*"] }),
 6  ]);
 7
 8  await chrome.storage.local.set({
 9    permissions: { bookmarks: hasBookmarks, github: hasGithub },
10  });
11}
12
13// Re-sync on every service worker startup
14chrome.runtime.onInstalled.addListener(syncPermissionState);
15chrome.runtime.onStartup.addListener(syncPermissionState);
16
17// Also listen for permission changes (user may revoke via chrome://extensions)
18chrome.permissions.onAdded.addListener(syncPermissionState);
19chrome.permissions.onRemoved.addListener(syncPermissionState);

Execution context: service worker, running at startup and in response to permission change events.

See service worker fundamentals for more on managing state across service worker lifetimes.

7. Remove permissions when no longer needed

Removing permissions reduces your extension’s attack surface and improves user trust. Only do this in response to explicit user action (a “Disable X” button), never silently or on a timer.

 1// popup.ts
 2const disableButton = document.getElementById("disable-github") as HTMLButtonElement;
 3
 4disableButton.addEventListener("click", async () => {
 5  const removed = await chrome.permissions.remove({
 6    origins: ["https://*.github.com/*"],
 7  });
 8
 9  if (removed) {
10    await chrome.storage.local.remove("permissions");
11    initPopup(); // re-render to show the "Enable" button
12  }
13});

Execution context: popup page script, inside a direct user-gesture event handler.

Cross-browser Variation

  • Chrome: Fully supports optional_permissions and optional_host_permissions in MV3. The user-gesture requirement is strictly enforced — calls from service workers throw synchronously in Chrome 112+.
  • Firefox: Supports optional_permissions in MV3 (still in progress). optional_host_permissions is recognized but some MV3 APIs remain in active development. Test with browser.permissions.request() for forward compatibility; the WebExtension polyfill normalizes both namespaces.
  • Safari / Web Extensions: Supports the optional_permissions key but the UI for granting permissions differs — Safari presents a per-site access sheet rather than a Chrome-style dialog. optional_host_permissions may not be fully supported depending on Safari version. Test on Safari 17+ for MV3 compatibility.
  • Edge: Shares Chromium’s implementation; behavior is identical to Chrome.

Verification

Open the extension popup and open DevTools (right-click the popup → “Inspect”). Check the following:

  1. On load, confirm the feature section is hidden and the “Enable” button is visible when permission has not been granted. The console should show no errors.
  2. Click the “Enable” button. A permission dialog should appear — if it does not, the call is not reaching a user-gesture handler. Check for any await before chrome.permissions.request().
  3. After granting, confirm chrome.storage.local contains the expected key by running in the console:
    1chrome.storage.local.get(null, console.log)
    
  4. Navigate to chrome://extensions, find your extension, and click “Details” → “Permissions” to confirm the optional permission appears as granted.
  5. Revoke the permission from chrome://extensions and re-open the popup — the feature section should hide again, confirming that chrome.permissions.onRemoved fired and state was updated.

FAQ

Why does chrome.permissions.request() return false without showing any dialog?

The call is not happening inside a synchronous user-gesture handler. Common causes: the call is in a setTimeout, behind an await that yields before chrome.permissions.request(), inside a chrome.runtime.onMessage listener in the service worker, or triggered programmatically on extension startup. Move the call to the first line of a click event listener with no preceding await.

Can I request permissions from a content script?

No. Content scripts run in the page context and do not have access to the chrome.permissions API. The request must originate from the extension’s own popup, options page, or a dedicated extension page. You can send a message from a content script to the popup to trigger a request, but the popup must still handle the actual chrome.permissions.request() call in a user-gesture context.

What happens if the user denies the permission and I call request() again?

Chrome does not permanently block re-requesting denied permissions — the dialog will appear again on the next call from a user gesture. However, if a user denies the same permission multiple times, Chrome may suppress future prompts for that session. Do not re-request automatically; only re-prompt when the user clicks “Enable” again.

Do optional permissions affect Chrome Web Store review?

Yes — reviewers check that optional permissions are genuinely optional (the extension works without them) and that the UI clearly explains why each permission is needed before requesting it. See passing Chrome Web Store review for what reviewers look for.

Other MV3 Architecture & Extension Lifecycle Resources