Injecting Only After a User Gesture with activeTab

Ship without a broad host permission: what activeTab actually grants, when the grant expires, which events count as a gesture, and how to recover when it has lapsed.

Published August 1, 2026 Updated August 1, 2026 8 min read
Table of Contents

"host_permissions": ["<all_urls>"] makes the code work and makes the install warning say your extension can read and change all your data on every website. Reviewers scrutinise it, users decline it, and most extensions do not need it — they need access to the one tab the user just clicked on. That is precisely what activeTab grants. This page is part of Scripting API & Dynamic Injection and covers what the grant covers, when it disappears, and how to build a feature that lives within it.

Root cause: activeTab is a temporary grant, not a permission

A host permission is static: declared in the manifest, approved at install, true forever. activeTab is dynamic: the browser grants access to one specific tab at the moment the user invokes your extension, and revokes it when they navigate away or close it. Code that assumes the grant is still there five minutes later is code that fails intermittently.

The lifetime of an activeTab grantThe grant begins at the user gesture, covers the current document in that tab, and ends at navigation, tab close or extension reload.user clicks the actiongrant goneGestureaction cli…Grant activeinject, read, insert CSSNavigationany URL ch…No accessexecuteScript throwstab.url becomes readablegrant revoked silently
The grant does not survive a navigation — not even a same-origin one.

Step 1. Declare the minimum and nothing more

1{
2  "manifest_version": 3,
3  "permissions": [
4    "activeTab",   // no install warning of its own
5    "scripting"    // still required — activeTab does not imply it
6  ],
7  // No host_permissions at all. Adding even one changes the install prompt.
8  "action": { "default_title": "Summarise this page" }
9}

Execution context: Read at install time. activeTab is one of the few permissions that produces no user-facing warning, which is exactly why it is worth designing around — the warning text is the single biggest driver of install abandonment, and the reasoning to present at review is covered in writing a permission justification that passes. All three engines implement activeTab with the same semantics; Safari layers its own per-site prompt on top, so the user may still be asked.

Step 2. Know which events count as an invocation

What does and does not grant activeTabEach extension entry point compared on whether it triggers an activeTab grant for the current tab.Entry pointGrants accessNotesaction.onClickedYesOnly with no default_popupClick inside the popupYesThe open counts as the gesturecommands.onCommandYesKeyboard shortcutcontextMenus.onClickedYesFor the tab the menu opened onalarms.onAlarmNoScheduled, not user-driventabs.onUpdatedNoBrowser eventruntime.onStartupNoNothing is active yet
Anything the browser schedules rather than the user triggering it grants nothing.

The row that surprises people is the first: if the manifest declares a default_popup, action.onClicked never fires at all — the popup opens instead. Opening the popup does grant activeTab, so a click on a button inside the popup can still inject; the gesture is the popup opening, not the button.

Two consequences follow from that table and are worth stating plainly. The first is that no background schedule can ever reach a page on its own: an alarm that wants to re-scan the user’s open tabs has no path to them, however recently the user granted access, because the grant died with the previous document. The second is that the grant is per tab and not per window — invoking the extension on one tab grants nothing for the tab beside it, so a feature that compares two pages needs the user to invoke it on both, or needs a host permission.

There is also a quieter benefit that only appears at review time. An extension whose manifest lists activeTab and no host patterns is making a claim a reviewer can verify by reading the code: it cannot touch a page the user did not point it at. That is a much shorter conversation than justifying <all_urls>, and it survives the periodic re-reviews that broad host permissions attract.

Step 3. Inject inside the gesture’s turn

 1// popup.js — the popup opening already granted access to the active tab
 2document.getElementById("summarise").addEventListener("click", async () => {
 3  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
 4
 5  const [injection] = await chrome.scripting.executeScript({
 6    target: { tabId: tab.id },
 7    func: () => document.body.innerText.slice(0, 20_000),
 8  });
 9
10  render(await summarise(injection.result));
11});

Execution context: Popup document, which holds the grant for as long as it is open. chrome.tabs.query returns a populated url here only because activeTab is in effect — without it, the field is absent and code that parses it fails with a confusing undefined rather than a permission error. Returning the extracted text from the injected function keeps the page data flowing back through the InjectionResult rather than through a message channel, which is one fewer surface to secure.

Step 4. Detect a lapsed grant and ask again

The grant ends on navigation. A long-lived surface — a side panel, an options page, a popup the user leaves open — will still be holding a tab id whose access has gone.

 1// panel.js
 2export async function withActiveTab(work) {
 3  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
 4
 5  try {
 6    return await work(tab);
 7  } catch (error) {
 8    const text = String(error);
 9    if (text.includes("Cannot access") || text.includes("host permission")) {
10      showReinvokePrompt(tab);            // "Click the toolbar icon to grant access again"
11      return null;
12    }
13    throw error;
14  }
15}

Execution context: Side panel or options document, or the service worker — the error text is the same wherever the call is made from. Chrome throws “Cannot access contents of the page. Extension manifest must request permission to access the respective host.”; Firefox and Safari word it differently, which is why the check matches on fragments rather than the whole string. There is no API to request activeTab programmatically: the user must invoke the extension again, so the only correct response is to say so clearly.

Step 5. Escalate to an optional host permission when the feature needs one

Some features genuinely cannot work within a per-gesture grant — anything that must run on every page load, or in the background. Ask for the host permission at the moment the user turns that feature on, not at install.

activeTab or an optional host permission?Whether the feature can wait for a user gesture decides which permission model applies and what the install prompt says.Can the feature wait for the user to invoke the extension?YesactiveTab onlyno install warningInject inside the gesture's turnre-prompt after a navigationNo, needs every page loadOptional host permissionrequested at opt-inRegister the script with the grantand unregister on revokeNo, and it is the core featureRequired host permissiondeclared in the manifestExpect review scrutinyjustify it precisely
Escalate per feature, at the moment it is switched on — never for the whole extension at install.
 1// options.js
 2document.getElementById("auto-summarise").addEventListener("change", async (event) => {
 3  if (!event.target.checked) {
 4    await chrome.permissions.remove({ origins: ["https://*/*"] });
 5    return;
 6  }
 7
 8  const granted = await chrome.permissions.request({ origins: ["https://*/*"] });
 9  event.target.checked = granted;         // the prompt may have been declined
10
11  if (granted) {
12    await chrome.scripting.registerContentScripts([{
13      id: "auto-summarise",
14      matches: ["https://*/*"],
15      js: ["auto.js"],
16      runAt: "document_idle",
17    }]);
18  }
19});

Execution context: Options page, from a user gesture — permissions.request is refused outside one in every engine. The origins must appear in optional_host_permissions in the manifest or the request throws. Registering the content script only after the grant is what keeps the two in step; unregister it in the remove branch or the registration outlives the permission and fails silently on every page. The full workflow is covered in requesting optional permissions at runtime.

Cross-browser variation

  • Chrome/Edge: activeTab covers the tab’s top-level frame and same-origin subframes. Cross-origin iframes need a matching host permission even during the grant.
  • Firefox: the same grant model. Firefox additionally exposes the grant in the add-ons manager, where a user can see which sites the extension has been given access to.
  • Safari: activeTab exists, but Safari’s own per-site permission prompt sits in front of it — the user may be asked to allow the extension on this website even after invoking it. Treat a failed injection as “not granted yet” rather than an error, as described in handling Safari web extension conversion gaps.
  • All engines: chrome:// and equivalent internal pages, the extension store, and PDF viewers are never injectable, with or without permissions.

Verification

  1. Load the extension with activeTab only and confirm the install prompt lists no host access.
  2. Invoke from the toolbar and confirm tabs.query returns a populated url. Invoke from an alarm handler and confirm it does not.
  3. Inject successfully, navigate the tab to another page in the same origin, then inject again from a still-open side panel. It should fail — the grant did not survive the navigation.
  4. Turn on the optional-permission feature, decline the prompt, and confirm the checkbox reverts rather than reporting success.
  5. Turn it off and confirm both the permission and the dynamic registration are removed.

FAQ

Does activeTab let me read the tab’s URL?

Yes, for the duration of the grant. Without it, tab.url and tab.title are omitted from tabs.query results entirely — which is the usual reason people think the API is broken.

Can I keep the grant alive by opening a port?

No. The grant is tied to the document, not to a connection; a navigation revokes it regardless of what is connected.

Is activeTab enough for a content script declared in the manifest?

No. Declarative content_scripts need matching host permissions, because they run without any gesture. activeTab only covers programmatic injection.

Does it work in every frame?

Only the top frame and same-origin children. Cross-origin frames need host_permissions for their origin.

Other Core APIs & Cross-Browser Data Management Resources