Declared vs Optional Permissions — The Two Grant Moments in Practice
~12 min · permissions, optional_permissions, chrome.permissions, user-gesture
Level 0Extension Curious
0 XP0/54 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"Declared permissions are the price of admission. Optional permissions are the upgrades you sell later. Lesson 2 is how the chrome.permissions API lets you defer the asks, gate the features, and recover from a 'no.'"
The chrome.permissions API
Four methods cover the runtime story:
chrome.permissions.contains({ permissions?, origins? }) — does the extension currently have these? Returns a boolean. Cheap, synchronous-feeling (Promise-based but resolves immediately).
chrome.permissions.request({ permissions?, origins? }) — ask the user to grant these now. MUST be called inside a user-gesture handler. Returns true if granted, false if denied or dismissed.
chrome.permissions.remove({ permissions?, origins? }) — drop a permission you previously had. Useful for letting the user revoke an upgrade they granted earlier.
Plus the change event: chrome.permissions.onAdded.addListener and chrome.permissions.onRemoved.addListener. Subscribe in the SW if you want to react to grants/revocations without polling.
The User-Gesture Constraint
chrome.permissions.request only works from inside a handler that Chrome considers a user gesture:
Click handlers on popup, side panel, or options page elements.
A message from a user-gesture-derived popup click (transitively).
Calling it from a timer, an alarm, a tabs event, or any other non-gesture context rejects with This function must be called during a user gesture. Don't try to fake it — the rejection is a security feature, not a bug.
The Two-Step UX Pattern
The clean shape for any optional-permission-gated feature:
Render the feature's UI normally (button, menu item, link).
On click, check chrome.permissions.contains first. If already granted, run the feature.
If not granted, call chrome.permissions.request. The Chrome prompt appears.
If the user grants, proceed to run the feature.
If the user denies, show a friendly inline message — "ClipDeck needs the Downloads permission to export. Try again to grant." — and keep the feature button visible.
This shape is symmetric and forgiving. The user can revoke any time (Chrome shows a Permissions tab in chrome://extensions/?id=...), then re-grant by triggering the same flow again.
Where the Request Lives
The handler can be anywhere with user-gesture access. Three common homes:
Popup button — popup.js click handler. Popup auto-closes on most chrome.permissions.request flows, so re-open the popup after grant if you need to continue.
Options page button — usually the home for opt-in feature configuration. Settings pages persist on screen during the request, which is the smoothest UX.
Side panel button — works the same as popup but stays open after the prompt.
The SW can't initiate the request directly (no user gesture in the SW), but it CAN receive a message from one of those surfaces and trigger downstream work after the surface confirms the grant.
What Optional Hosts Look Like
optional_host_permissions works the same way but for URL patterns. Use it when ClipDeck needs to inject into a site the user didn't sign up for at install. Example: "Allow ClipDeck on this site" button in the popup that requests https://<current-host>/*. Chrome shows the dialog "Allow ClipDeck to read and change data on this site?" The user clicks Allow and from then on the content script auto-injects there.
Declared = essential, paid at install. Optional = nice-to-have, paid when the user reaches for it. chrome.permissions.contains + chrome.permissions.request + a graceful 'denied' path is the full UX.
The popup-closes-on-request gotcha. On many Chromes, the permission prompt closes the popup. If your popup flow was "click → request → run feature," the 'run feature' code path never executes — the popup is gone. Move the actual feature work into the SW (the popup messages it after grant) or into a side panel (which stays open) so the post-grant action is reachable.
Code
popup.js — ensure permission, then delegate work to the SW·javascript
// popup.js — feature-gated by chrome.permissions.request
async function ensureDownloadsPermission() {
const has = await chrome.permissions.contains({ permissions: ["downloads"] });
if (has) return true;
return chrome.permissions.request({ permissions: ["downloads"] });
}
document.getElementById("exportBtn").addEventListener("click", async () => {
const ok = await ensureDownloadsPermission();
if (!ok) {
document.getElementById("exportStatus").textContent =
"Downloads permission denied. Click Export again to retry.";
return;
}
// Trigger the actual export. The popup may close on the prompt, so the
// safer pattern is to message the SW and let it own the download.
await chrome.runtime.sendMessage({ type: "exportClips" });
document.getElementById("exportStatus").textContent = "Exporting…";
});
background.js — SW owns chrome.downloads after the popup confirms grant·javascript
background.js — listen to onAdded / onRemoved for live UI reactivity·javascript
// background.js — react when the user grants or revokes any permission
chrome.permissions.onAdded.addListener((perms) => {
console.log("[ClipDeck SW] permissions granted:", perms.permissions, perms.origins);
});
chrome.permissions.onRemoved.addListener((perms) => {
console.log("[ClipDeck SW] permissions revoked:", perms.permissions, perms.origins);
// For example, if downloads was revoked, disable the Export button in the popup
// (the popup re-reads chrome.permissions.contains on each open anyway).
});
Add an Export Clips button to clipdeck/popup.html with id exportBtn and a status div exportStatus. Add the first code block to clipdeck/popup.js and the second to clipdeck/background.js. Reload. Open the popup, click Export Clips. Chrome shows the 'Add Downloads' prompt. Click Allow — a Save dialog appears with your clip export. Open the popup again; the permission is now granted, so the second export is one click. Then go to chrome://extensions/?id=<ClipDeck id> → Permissions tab, revoke Downloads, and try again — the prompt reappears. Confirm the popup status message shows on denial.
Hint
If chrome.permissions.request rejects with This function must be called during a user gesture, the user gesture context was lost — usually because you awaited something else before calling request. Call request FIRST in the handler, then do other work. If chrome.downloads.download errors with permission not granted despite the prompt showing Allow, the SW's permissions snapshot is stale — calling chrome.permissions.contains again at the start of the SW handler reads the current grant. The popup-closes gotcha bites here: don't put 'continue export' logic after the request in popup.js; move it to the SW as shown.
Progress
Progress is local-only — sign in to sync across devices.