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.
Table of Contents
- Root cause: activeTab is a temporary grant, not a permission
- Step 1. Declare the minimum and nothing more
- Step 2. Know which events count as an invocation
- Step 3. Inject inside the gesture’s turn
- Step 4. Detect a lapsed grant and ask again
- Step 5. Escalate to an optional host permission when the feature needs one
- Cross-browser variation
- Verification
- FAQ
- Related
"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.
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
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.
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:
activeTabcovers 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:
activeTabexists, 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
- Load the extension with
activeTabonly and confirm the install prompt lists no host access. - Invoke from the toolbar and confirm
tabs.queryreturns a populatedurl. Invoke from an alarm handler and confirm it does not. - 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.
- Turn on the optional-permission feature, decline the prompt, and confirm the checkbox reverts rather than reporting success.
- 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.
Related
- Bridging data between MAIN world and isolated world — what to do once the injection lands.
- Injecting CSS and JS with executeScript — the call this grant authorises.
- Requesting optional permissions at runtime — escalating when the gesture model is not enough.
- Scripting API & Dynamic Injection — the guide this page belongs to.