Justifying Sensitive Data Permissions
Get bookmarks, history and downloads permissions through Chrome Web Store, AMO and App Store review — narrowing scope, writing the justification, and filling the data disclosure form.
Table of Contents
Declaring "history" moves your extension into a review lane where every network request is questioned and the listing copy is read closely. The permission itself is not the problem — reviewers approve history-backed extensions every day. What gets rejected is a permission whose purpose the reviewer cannot map to something the user visibly asked for. This guide is part of bookmarks, history and downloads APIs.
What the reviewer is checking
Step-by-step
1. Move every sensitive permission to optional
This is the single highest-leverage change. An optional permission produces no install warning, and the prompt appears attached to the feature that needs it.
1{
2 "permissions": ["storage", "alarms"],
3 "optional_permissions": ["history", "bookmarks", "downloads", "topSites"]
4}
Execution context: parsed at install. The user sees a short install prompt and a specific, contextual one later — the pattern set out in requesting optional permissions at runtime.
2. Attach each request to a visible action
1// options.js — the button says what it will do before the prompt appears
2document.querySelector("#enable-report").addEventListener("click", async () => {
3 const granted = await chrome.permissions.request({ permissions: ["history"] });
4 if (!granted) {
5 status.textContent = "Reading reports stay off. Nothing else changes.";
6 return;
7 }
8 await chrome.runtime.sendMessage({ type: "report:enable" });
9});
Execution context: the options page, inside a click handler — permissions.request is refused outside a user gesture. The copy matters as much as the code: a refusal must read as a supported choice, not a failure.
3. Write the justification the way the form wants it
Each store asks for a per-permission reason. A usable one names the feature, the API, and the boundary.
history — Powers the “Reading report” screen, which the user enables explicitly in Options. We call
chrome.history.searchover the last 30 days, aggregate visit counts per domain locally, and display the result. No history data, including URLs, is transmitted off the device at any point.
Three things make that work: it names a screen a reviewer can open, it names the call, and it states the network boundary as a fact rather than a promise.
4. Make the claim true in the code
1// A single choke point for anything that leaves the device, so the claim is auditable.
2const ALLOWED_FIELDS = new Set(["window", "topCategories", "version"]);
3
4export async function sendTelemetry(payload) {
5 const leaked = Object.keys(payload).filter((k) => !ALLOWED_FIELDS.has(k));
6 if (leaked.length) throw new Error(`refusing to send fields: ${leaked.join(", ")}`);
7 await fetch(TELEMETRY_URL, { method: "POST", body: JSON.stringify(payload) });
8}
Execution context: the service worker. An allowlist that throws is worth more than a comment: it fails the build when someone adds a field, and it is a two-line answer when a reviewer asks what you send.
5. Fill the data disclosure form to match
Every store has a version of this. Answer it against the code, not against intent:
- Web history — tick it if you call
chrome.historyat all, even if nothing is transmitted. The question is whether you access it. - Website content — tick it if a content script reads page text.
- Personally identifiable information — tick it if you store an account identifier.
- Data is sold or transferred — answer no, and mean it.
A mismatch between the form and the code is the most reliably fatal error in the whole process, because it reads as deception rather than as an oversight.
6. Handle revocation as a first-class state
A user can withdraw an optional permission at any time from the browser’s own UI, and your code must not throw when they do.
1chrome.permissions.onRemoved.addListener(async ({ permissions = [] }) => {
2 if (!permissions.includes("history")) return;
3 await chrome.storage.local.remove(["reportCache", "reportCursor"]);
4 await chrome.alarms.clear("report-refresh");
5 chrome.runtime.sendMessage({ type: "report:disabled" }).catch(() => {});
6});
Execution context: the service worker, registered at the top level. Deleting the derived cache is not merely tidy — leaving a history-derived report on disk after the permission is withdrawn is exactly the kind of thing a user reports.
Anatomy of a rejection, and how to answer one
Rejection emails are short and the reason codes are generic. Translating one into an action is most of the work, and the translations are surprisingly consistent.
“Request minimum permissions” almost never means the permission is disallowed. It means the reviewer could not find the feature that needs it, or found that a narrower permission would do. The answer is not an appeal; it is a build that moves the permission to optional_permissions and a listing screenshot showing the screen that requests it.
“Unclear purpose” / “single purpose violation” means the listing describes a product whose shape does not obviously require the data. An extension described as “a dark mode toggle” that reads history will be rejected regardless of how good the justification field is, because the listing is the reviewer’s first document. Fix the description before the code.
“Privacy policy does not cover the data collected” is the literal one. The policy must name the categories you ticked in the disclosure form, in ordinary language, at a stable URL you control.
When you reply, three things make an appeal effective: a specific reproduction path (“Options → Reading report → Enable”), a named code location, and an explicit statement of the network boundary. A reviewer reading twenty appeals an hour will act on that and will not act on a paragraph of reassurance.
1Feature: Options → "Reading report" → Enable
2Permission requested there: history (optional, via chrome.permissions.request)
3Code: src/report/collect.js — chrome.history.search over 30 days, aggregated in-memory
4Network: src/telemetry.js is the only fetch() in the bundle; it sends {window, topCategories, version}
5 and throws on any other field (allowlist at line 12).
Execution context: this is text for the appeal form, not code — but the allowlist it points at is the thing that makes it checkable, which is why the allowlist in step 4 is worth more than a comment.
One more practical note: resubmit once, with every identified issue fixed. Repeated submissions for the same violation lengthen review times, and a second rejection for a second reason reads much worse than one submission that addressed everything.
Cross-browser variation
- Chrome / Edge: the developer dashboard requires a written justification per permission and a completed privacy-practices section. Extensions holding
historythat also make network calls are the most commonly delayed category. - Firefox (AMO): reviewers read source. If your bundle is minified you must supply build sources, which means the allowlist in step 4 is something a human will actually see — a real advantage.
- Safari / App Store: permissions map onto the app’s privacy nutrition label rather than a per-permission form. The same discipline applies; the paperwork differs.
- All three: the fastest rejection is a permission declared but never called. Audit the built bundle, not the source tree, before every upload — the check in passing Chrome Web Store review.
Verification
- Confirm what the installed extension actually holds:
1await chrome.permissions.getAll();
2// { permissions: ["storage", "alarms"], origins: [] } ← before the user opts in
Execution context: the service worker console. A sensitive permission appearing here on a fresh install means it is still in permissions rather than optional_permissions.
- Grep the built bundle for each declared permission’s namespace and confirm every one has a call site.
- Open DevTools Network, generate a report, and confirm the only outbound request carries the allowlisted fields.
- Revoke the permission from
chrome://extensions → Detailsand confirm the feature disables cleanly with no console errors.
FAQ
Will an optional permission hurt my install conversion?
The opposite, usually. A short install prompt converts better, and the contextual prompt lands on a user who has just clicked the feature. The cost is the code path for “not granted”, which you need anyway.
Do I need a privacy policy if nothing leaves the device?
You still need to complete the disclosure form, answering that you access the data and share none of it. Many reviewers also expect a hosted policy for any extension that declares these permissions — a short, honest page is enough.
What if my feature genuinely needs the whole history?
Then say so plainly, bound it (“the last 90 days”), and keep the processing local. Reviewers reject vagueness far more often than scope.
Related
- Requesting optional permissions at runtime — the mechanics of the contextual prompt.
- Writing a permission justification that passes — the wording, in more depth.
- Searching and pruning browsing history — keeping the processing local.
- Bookmarks, history and downloads APIs — the parent guide.