Host Permissions & Site Access
How MV3 extensions get access to websites: host_permissions, optional hosts, activeTab, Chrome's user site-access controls, incognito modes, Firefox's revocable grants and Safari's per-site prompts.
Every capability that touches a website — injecting a content script, reading a tab’s URL, calling chrome.cookies, observing requests, fetching a page without CORS — is gated by host permissions. In Manifest V2 they were a static list granted at install and never revisited. In Manifest V3 they are a negotiation that continues for the extension’s whole life: Chrome lets users restrict any extension to “on click” or a short list of sites, Firefox lets users revoke hosts at any time, Safari asks per site with options as short as one day, and enterprise policy can block hosts outright. An extension that assumes its declared hosts are granted fails silently for a growing share of users. This topic sits inside Manifest V3 Architecture & Extension Lifecycle, and the most important decision in it is the one covered in activeTab versus host permissions.
The pattern that holds up treats host access as runtime state rather than configuration. Request the narrowest hosts that make the feature work, prefer temporary grants from user gestures, check access before every operation that needs it, react when access changes, and design a degraded mode for sites the user has not opened up to you.
Prerequisites checklist
- A list of every feature that needs site access, and for each one whether it needs to work without a user gesture.
-
host_permissionslimited to hosts needed at install; everything else inoptional_host_permissions. -
activeTabdeclared if any feature runs on “the current page when the user clicks”. - A
chrome.permissions.containscheck before operations that depend on a host, with a UI path for “not granted”. - Listeners for
chrome.permissions.onAddedandonRemovedregistered at the top level of the service worker. - An explicit
incognitosetting chosen for the extension’s data model.
Manifest registration
1{
2 "manifest_version": 3,
3 "name": "Readable",
4 "version": "4.0.0",
5 "permissions": [
6 "activeTab", // temporary access to the current tab on a user gesture
7 "scripting", // inject into it with chrome.scripting
8 "storage"
9 ],
10 "host_permissions": [
11 "https://api.readable.example/*" // our own API: needed for every user, from install
12 ],
13 "optional_host_permissions": [
14 "https://*/*" // "run automatically on every site" — opt-in only
15 ],
16 "incognito": "spanning"
17}
Execution context: parsed at install. Chrome shows install warnings for host_permissions only; activeTab and optional_host_permissions produce none until requested at runtime. Firefox shows host_permissions in its install prompt from version 127 but lets the user revoke them afterwards. Safari ignores the install-time distinction and asks per site when the extension first tries to act there.
1. Temporary access with activeTab
activeTab grants host permission for the active tab when the user invokes the extension — clicking the action, choosing a context menu item, pressing a declared keyboard shortcut, or accepting an omnibox suggestion. The grant lasts until the tab navigates to a different origin or closes. It produces no install warning, survives Chrome’s “on click” setting by definition, and is the single most effective way to make an extension both useful and trusted.
1// sw.js
2chrome.action.onClicked.addListener(async (tab) => {
3 // activeTab has just granted access to tab.id
4 await chrome.scripting.executeScript({
5 target: { tabId: tab.id },
6 files: ["reader-mode.js"],
7 });
8});
Execution context: the service worker. The onClicked event only fires when the action has no popup; with a popup, the grant happens when the popup opens and the popup can call chrome.scripting for the active tab. Firefox and Safari support activeTab with the same semantics. The trade-off against declared hosts — what you lose when nothing runs automatically — is covered in activeTab versus host permissions.
2. Optional hosts requested in context
Features that must run without a click — auto-highlighting on every page, background monitoring of a site — need persistent access. Declaring it in host_permissions costs an install warning shown to every user, including those who will never enable the feature. optional_host_permissions defers the warning to the moment a user turns the feature on.
1// options.js — must run from a user gesture
2document.querySelector("#auto-mode").addEventListener("change", async (e) => {
3 if (!e.target.checked) return chrome.permissions.remove({ origins: ["https://*/*"] });
4 const granted = await chrome.permissions.request({ origins: ["https://*/*"] });
5 e.target.checked = granted;
6 if (granted) chrome.runtime.sendMessage({ type: "auto-mode:on" });
7});
Execution context: the options page. permissions.request must be called synchronously within a user gesture handler — awaiting anything first loses the gesture and the request is rejected. Removing the permission when the user turns the feature off keeps the grant honest and is something reviewers notice. The general request flow is in requesting optional permissions at runtime.
3. The user’s site access setting
Chrome’s extensions menu lets the user choose, per extension, whether it can read and change site data “on click”, “on specific sites” or “on all sites” — regardless of what the manifest requested. Choosing “on click” withdraws every declared host until the user clicks the extension on a given site. The manifest is unchanged; chrome.permissions.getAll() simply stops listing the withdrawn hosts, content scripts declared for those hosts stop injecting, and API calls scoped to them return empty results.
1// sw.js — ask the browser what is actually granted right now
2export async function canRunOn(url) {
3 const { origin } = new URL(url);
4 return chrome.permissions.contains({ origins: [`${origin}/*`] });
5}
Execution context: the service worker or any extension page. contains reflects user restrictions, activeTab grants and optional grants in one answer. Call it before acting rather than caching a result from install time. Handling the “on click” case gracefully — badge hints, a request prompt in the extensions menu, a clear popup message — is covered in handling user-restricted site access.
4. Reacting to grants and revocations
Grants change while the extension runs: the user approves an optional request, restricts site access, or a policy update arrives. The permissions events let every part of the extension adapt — registering content scripts for newly granted hosts, unregistering them for revoked ones, and updating the options page.
1// sw.js — top level
2chrome.permissions.onAdded.addListener(({ origins = [] }) => syncContentScripts(origins, "add"));
3chrome.permissions.onRemoved.addListener(({ origins = [] }) => syncContentScripts(origins, "remove"));
4
5async function syncContentScripts() {
6 const { origins = [] } = await chrome.permissions.getAll();
7 const matches = origins.filter((o) => o !== "https://api.readable.example/*");
8 await chrome.scripting.unregisterContentScripts({ ids: ["auto"] }).catch(() => {});
9 if (matches.length) {
10 await chrome.scripting.registerContentScripts([{ id: "auto", matches, js: ["auto.js"], runAt: "document_idle" }]);
11 }
12}
Execution context: the service worker, registered at the top level so a grant wakes it. Recomputing from getAll() rather than patching from the event’s payload avoids drift when several events arrive together. Chrome fires onRemoved when the user restricts site access; Firefox fires it when the user toggles a host off in the add-on’s permissions panel; Safari’s per-site grants do not fire these events reliably, so re-check with contains before acting there. Details are in detecting when host permissions are granted or revoked.
5. Incognito and private windows
Extensions do not run in incognito unless the user allows them, and when they do, the incognito manifest key decides how. "spanning" (the default) runs one service worker that receives events from both normal and incognito windows; "split" runs a separate instance inside incognito with its own memory and storage view; "not_allowed" prevents the extension from ever running there. The choice affects host access indirectly: cookie stores, tab ids and content script injection all behave differently depending on the mode.
1chrome.extension.isAllowedIncognitoAccess().then((allowed) => {
2 document.querySelector("#incognito-hint").hidden = allowed;
3});
Execution context: an extension page. There is no API to request incognito access — the user must toggle it in the extension’s details page, so the most you can do is detect it and explain. Firefox calls it “Run in Private Windows” and does not support "split". The trade-offs are in extensions in incognito: split versus spanning.
6. Designing a degraded mode
Because access can be missing on any given site, every feature that depends on it needs an answer to “what do we show when we cannot act here?”. The worst answer is silence: a popup that shows an empty list, a badge that never appears, a button that does nothing. Users read silence as “broken” and uninstall. The best answer explains in one line what is missing and offers the gesture that fixes it.
1// popup.js
2const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
3const access = await chrome.runtime.sendMessage({ type: "access:check", url: tab.url });
4
5if (access === "restricted-page") {
6 render("Extensions can't run on browser pages like this one.");
7} else if (access === "no-host") {
8 render("Readable isn't allowed on this site yet.", {
9 action: "Allow on " + new URL(tab.url).hostname,
10 onClick: () => chrome.permissions.request({ origins: [new URL(tab.url).origin + "/*"] }),
11 });
12} else {
13 renderArticleTools(tab);
14}
Execution context: the popup. Opening the popup is itself a user gesture, so the “Allow” button can call permissions.request directly from its click handler — provided the origin is covered by optional_host_permissions. restricted-page covers chrome://, the Web Store and other pages no extension can touch, which permissions.contains would otherwise report as simply “not granted”. Distinguishing the two prevents you from asking for a grant the browser will never give.
7. Auditing what you actually use
Permissions accumulate. A host added for a feature that was later removed, a wildcard added during debugging, a "tabs" permission requested because one line needed tab.url — each widens the install warning and the review surface without adding value. Audit before each release by comparing what the manifest declares with what the code calls.
1# Every host pattern the manifest declares
2jq -r '.host_permissions[]?, .optional_host_permissions[]?, (.content_scripts[]?.matches[]?)' dist/manifest.json | sort -u
3
4# Every place the code touches a host-gated API
5grep -rnoE "chrome\.(scripting|cookies|webRequest|tabs\.(captureVisibleTab|executeScript))" src | sort | uniq -c
Execution context: a terminal or CI step on the built extension. A declared host that no content script matches and no API call targets is a candidate for removal or for moving to optional_host_permissions. Run the same audit after enabling activeTab: many host patterns become unnecessary once user-initiated features use the temporary grant instead.
Treat a new host pattern in a pull request the way you would treat a new dependency: it needs a reason in the description, a reviewer who checks that the narrowest pattern was chosen, and a note in the store listing’s permission justification. Adding a host to an already-published extension also has a user-facing cost — Chrome disables the extension after the update until the user accepts the new warning, and a meaningful fraction never do. A host that can live in optional_host_permissions avoids that re-consent entirely, because optional permissions do not appear in the install or update warning.
Cross-cutting concerns: review, trust and policy
Host permissions are the single biggest factor in store review time and user trust. Chrome Web Store reviewers apply in-depth review to extensions requesting broad hosts such as <all_urls> or *://*/*, and the install dialog’s “Read and change all your data on all websites” is the warning most likely to make a user abandon the install. The arithmetic is covered in the review cost of all_urls and broad host patterns. Narrow hosts, activeTab, and optional permissions requested in context each reduce both costs.
Enterprise administrators add another layer. The ExtensionSettings policy’s runtime_blocked_hosts prevents an extension from touching listed hosts no matter what it declares or the user grants, and runtime_allowed_hosts carves exceptions. Calls into blocked hosts fail the same way an ungranted host does, so the defensive checks above cover policy for free — as long as your error messages do not tell an enterprise user to “grant access”, which they cannot.
Security posture matters too. A host permission is a capability an attacker inherits if they compromise your extension or its update channel. Broad hosts turn a single vulnerability into access to every site the user visits. Narrow hosts limit the blast radius.
MV3 constraints box
- Declared is not granted. Chrome users can withdraw any declared host; Firefox users can revoke them; Safari asks per site. Always check with
permissions.contains. - Requests need a gesture.
permissions.requestfails unless called synchronously in a user gesture handler in an extension page. - activeTab is per tab and per origin. Navigation to another origin ends the grant; a new tab needs a new gesture.
- Content scripts follow grants. Manifest-declared content scripts silently stop injecting on withdrawn hosts; dynamic registrations must be updated.
- No incognito request API. Users must enable incognito access themselves.
- Policy beats everything.
runtime_blocked_hostscannot be overridden by the extension or the user.
Cross-browser notes
| Capability | Chrome / Edge | Firefox | Safari |
|---|---|---|---|
host_permissions at install | Granted, user may restrict | Shown at install (127+), revocable | Asked per site on first use |
optional_host_permissions | Yes | Yes | Yes, maps to per-site prompts |
activeTab | Yes | Yes | Yes |
| User site-access menu | On click / specific / all | Per-host toggles | Per-site, incl. “for one day” |
permissions.onAdded/onRemoved | Yes | Yes | Unreliable for per-site grants |
"incognito": "split" | Yes | No (treated as spanning) | No |
Firefox’s history matters for older users: before version 127 MV3 host permissions were not granted at install at all and had to be enabled from the add-ons manager. Code written for that period — prompting for hosts on first run — still works and remains good practice, as host permissions in Firefox MV3 are optional explains.
What this section covers
Start with activeTab versus host permissions to choose the model; then handling user-restricted site access for Chrome’s on-click mode; the review cost of all_urls and broad host patterns for the store side; extensions in incognito: split versus spanning for private windows; detecting when host permissions are granted or revoked for runtime changes; and host permissions in Firefox MV3 are optional for Firefox.
Covered elsewhere: non-host permissions requested at runtime are in store submission and permissions compliance, and the URL syntax itself is in writing match patterns and globs.
Related
- Store submission and permissions compliance — justifying the hosts you do request.
- Content scripts and DOM injection — what host access lets you inject.
- Scripting API and dynamic injection — injecting into granted tabs.
- Manifest keys reference — every key that touches site access.
- Manifest V3 Architecture & Extension Lifecycle — the parent section.