C.W.K.
Stream
Lesson 04 of 05 · published

activeTab and Other Narrow Permissions — Avoiding the Scary Warnings

~11 min · activeTab, permissions, narrow-permissions, ux

Level 0Extension Curious
0 XP0/54 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"Half of Chrome's most-installed extensions never get the all-websites warning, because they use activeTab and a handful of narrow permissions that solve the same problems. Lesson 4 is the shortcut list — what to reach for instead of <all_urls>."

activeTab — The Workhorse

"activeTab" is a special API permission that grants temporary host access to the currently-active tab, starting when the user invokes the extension via:

  • Toolbar icon click (chrome.action.onClicked or popup open).
  • Keyboard command (chrome.commands.onCommand).
  • Context menu (chrome.contextMenus.onClicked).

The grant covers: chrome.scripting.executeScript on that tab, chrome.tabs.captureVisibleTab on that tab, and reading tab.url / tab.title. It lasts until the user navigates the tab to a new URL or closes the tab.

Crucially: no install warning. activeTab by itself adds nothing to the prompt. Combined with the user-gesture trigger pattern, you get "works on demand, only when asked" semantics without the all-sites alarm.

activeTab vs Programmatic Injection

Confusion point: activeTab grants host access; scripting grants the API. You usually want both:

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

With those two declared, your SW can call chrome.scripting.executeScript on the user-active tab inside a gesture handler. No host_permissions needed. No content-script declaration needed unless you also want always-on injection.

Other Narrow Permissions Worth Knowing

  • tabGroups — read and modify Chrome's tab grouping. No install warning of its own.
  • notifications — show desktop notifications via chrome.notifications.create. Mild install warning ("Show notifications"); benign for most users.
  • alarms — schedule one-shot or recurring SW wake-ups. No install warning. Replaces setInterval/setTimeout (which die with the SW).
  • identity — OAuth flows for Google sign-in. Install warning summarizes the scopes you'll later request, not host access.
  • webNavigation — fine-grained navigation events. Comes with a "Read your browsing history" warning; tabs covers most of the same ground less scarily.
  • contextMenus — already in your manifest. No warning.
  • storage, commands, sidePanel, omnibox, action — all silent.

The Permissions to Treat With Care

These each add their own loud warning. Use only when you truly need them, and consider gating behind optional_permissions:

  • history — "Read and modify your browsing history." Rarely needed; tabs plus a per-tab cache usually covers the use case.
  • cookies — "Read and modify cookies on all sites." Almost never needed for a clip-saving extension.
  • geolocation — "Detect your physical location." Surprises users; ask runtime.
  • nativeMessaging — "Communicate with cooperating native applications." Lets you talk to a desktop binary; opens an attack surface.
  • debugger — "Use the developer tools on any tab." Power-user only; install warning is loud and justified.
  • webRequest — "Block or modify network requests." MV3 restricted this heavily compared to MV2; even narrow use produces a warning.

The Substitution Game

Before declaring a host permission or a scary API permission, ask: "is there a narrower way?" Often yes:

  • Need a periodic background job? alarms, not setInterval + persistent worker.
  • Need to remember per-site preferences? storage, not cookies.
  • Need to read the current page? activeTab + scripting, not host_permissions.
  • Need to know which sites the user just visited? tabs with onUpdated, not history or webNavigation.
  • Need to act when the user types something? commands for a hotkey, or omnibox for an address-bar keyword. No debugger.
activeTab + scripting + storage is the no-warning trinity that covers a huge fraction of real extensions. Reach for narrow permissions first; declare host_permissions only when no narrower path exists.
How activeTab interacts with content_scripts.matches. activeTab does NOT auto-inject a declared content script — it just unlocks chrome.scripting and tab.url access. If you want a content script to load when activeTab is invoked, you must call chrome.scripting.executeScript from the gesture handler. The static content_scripts manifest entry is always-on for matching URLs and is governed by content_scripts.matches, separately from activeTab.

Code

background.js — activeTab unlocks scripting on the user-clicked tab·javascript
// background.js — activeTab + scripting in a gesture handler
chrome.action.onClicked.addListener(async (tab) => {
  if (!tab.id) return;
  // activeTab + scripting permits this even without host_permissions
  const [result] = await chrome.scripting.executeScript({
    target: { tabId: tab.id },
    func: () => ({ url: location.href, title: document.title }),
  });
  console.log("[ClipDeck SW] page info:", result.result);
});
background.js — chrome.alarms replaces setInterval for periodic SW work·javascript
// background.js — alarms for periodic work without keeping the SW alive
chrome.runtime.onInstalled.addListener(() => {
  chrome.alarms.create("clipdeck-cleanup", {
    when: Date.now() + 60_000, // first run in 1 minute
    periodInMinutes: 60, // then every hour
  });
});

chrome.alarms.onAlarm.addListener(async (alarm) => {
  if (alarm.name !== "clipdeck-cleanup") return;
  // Prune stale tab ids from pausedTabs (from Track 5)
  const { pausedTabs = [] } = await chrome.storage.local.get("pausedTabs");
  const tabs = await chrome.tabs.query({});
  const live = new Set(tabs.map((t) => t.id));
  const trimmed = pausedTabs.filter((id) => live.has(id));
  if (trimmed.length !== pausedTabs.length) {
    await chrome.storage.local.set({ pausedTabs: trimmed });
  }
});
manifest.json — the narrow-permission ClipDeck shape·json
{
  "permissions": [
    "storage",
    "activeTab",
    "scripting",
    "sidePanel",
    "contextMenus",
    "commands",
    "alarms",
    "tabs"
  ],
  "host_permissions": [],
  "optional_permissions": ["downloads", "notifications"],
  "optional_host_permissions": ["https://*/*", "http://*/*"]
}

External links

Exercise

Update clipdeck/manifest.json to the third code block — add "alarms" to permissions, keep host_permissions empty, and move broad URL access into optional_host_permissions. Reload. In chrome://extensions → ClipDeck → Details, the install warning should now read 'Read your browsing history' (from tabs) and 'Read and change your data on sites you visit' (from content_scripts.matches still being <all_urls>) — narrower than 'all websites' if you've narrowed content_scripts.matches in Lesson 3, otherwise still loud. Add the second code block (alarms cleanup) to background.js. Save several clips, close some of those tabs without un-pausing them, wait an hour (or change the alarm timing to periodInMinutes: 1 for testing). The pausedTabs cleanup should run and prune dead tab ids — confirm by checking chrome.storage in DevTools before and after.
Hint
If the install warning is unchanged after dropping host_permissions, content_scripts.matches: ['<all_urls>'] is still doing the work — narrow it (Lesson 3) or switch to activeTab + programmatic injection (this lesson). If chrome.alarms.onAlarm never fires, the alarm was probably not created because chrome.runtime.onInstalled didn't run yet — try removing the extension and re-loading the unpacked directory, which triggers a fresh onInstalled. The chrome.alarms minimum period in MV3 is 30 seconds; values below that are silently rounded up.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.