Permissions API: the two answers that must agree
There is a headless check that has been in every detection suite for years and takes two lines:
const a = Notification.permission; // "denied"?
const b = (await navigator.permissions.query({name: 'notifications'})).state; // "prompt"?
a === 'denied' && b === 'prompt' // historically: headless
Two APIs, one question, and a browser where they disagree. It is the same shape as everything else in this subject: nobody had to recognise a headless browser, they only had to ask twice.
This page is why the mismatch exists, why the whole permission set is a fingerprint of its own, what automation frameworks do to it, the grant that quietly disables protection elsewhere, and what a normal browser looks like.
Why the two answers can differ
Notification.permission is a property on the Notification API. The Permissions API is a separate, newer interface designed to give a uniform way to query any permission. They describe the same state and they reach it through different code.
A browser that implements one path fully and the other partially, or an environment where notifications are not really available while the permission machinery still answers, ends up with two different answers to the same question. Historically that is what headless builds did.
The lesson generalises past this pair, and it is the reason this check keeps working after the specific bug is fixed:
Wherever a browser exposes the same fact through two interfaces, they are a consistency check. You do not need to know which one is correct. You only need them to disagree.
The same idea appears in the canvas run in a page and in an iframe, hardwareConcurrency read from a worker, and a fresh copy of a built-in taken from an iframe.
The permission set is a fingerprint
Beyond the mismatch, the states themselves carry information. The Permissions API is enumerable in practice: you query the names you care about and collect the answers.
const names = ['notifications', 'geolocation', 'camera', 'microphone',
'clipboard-read', 'clipboard-write', 'persistent-storage',
'midi', 'background-sync'];
What that set says about you:
- A browser where everything is
promptis the overwhelmingly common case, because most people never answer a permission dialog on most sites. - A browser where everything is
grantedis rare, and it is what happens when automation grants everything to avoid dialogs blocking a script. - A browser where everything is
deniedis also rare, and it is what happens when someone sets a blanket deny to avoid prompts. - A specific pattern of grants is more identifying than any single one, because it describes a history of decisions.
So the instinct to grant everything, which is very common in automation because it stops dialogs interrupting a flow, produces an unusual browser. It also has a specific consequence for WebRTC that is worse than the dialog you avoided.
What automation does to permissions
Two mechanisms, and the difference matters.
Granting at the context level. Most frameworks let you pre-grant permissions when creating a context. This is scoped to that context and disappears with it, which is the tidy version.
Granting through the browser’s own UI or profile. A permission granted this way is written into the profile and survives. In a persistent profile it survives forever, or until something removes it.
Both change what the Permissions API reports. Only the second follows you into future sessions, and that is the one that causes trouble.
The grant that turns off something else
This is the part worth knowing even if you never think about permissions.
Firefox conditions two separate WebRTC address protections on the absence of camera or microphone access: restricting ICE gathering to the default route, and masking the host candidate behind an mDNS name. A granted permission switches both off together.
The check counts a persisted grant, not only an active capture. So a camera permission granted once, in a profile you reuse, disables those protections for that origin in every future session on that profile.
The persistent profile page covers the audit, and the WebRTC page covers what is being switched off. The short version: if you use a persistent profile with a proxy, do not grant camera or microphone unless you mean it, and check what is already stored.
What a normal browser looks like
Working backwards from all of the above:
- The two notification answers agree. Whatever the state is, both APIs report it.
- Most states are
prompt. That is what not having answered dialogs looks like. - Grants are specific and few, matching the sites the profile has actually used.
- Nothing is granted that the session never asked for, especially camera and microphone.
- The set is stable for the identity, because permission decisions do not evaporate.
The general rule is the same as elsewhere: aim for the state a real browser would be in, not for the state that is most convenient for your script.
Checking your own
const names = ['notifications', 'geolocation', 'camera', 'microphone',
'clipboard-read', 'persistent-storage'];
const out = {};
for (const name of names) {
try { out[name] = (await navigator.permissions.query({name})).state; }
catch (e) { out[name] = 'unsupported'; }
}
out['Notification.permission'] = Notification.permission;
console.table(out);
What to look for:
notificationsfrom the Permissions API andNotification.permissionsay the same thing.- Most entries are
prompt. cameraandmicrophoneare notgrantedunless you intended that.- Nothing throws that a real browser answers, and nothing answers that a real browser does not implement, since a name your claimed browser does not support should be
unsupportedthere too.
Then run the same snippet in a stock browser on the same machine and compare the shape, which is the method that catches what verdicts miss.
Conclusion
The permission surface is small, cheap to read and easy to get wrong in the direction that feels helpful. Granting everything to stop dialogs interrupting a script produces an unusual browser and, on Firefox with a persistent profile, quietly removes protection you set up somewhere else.
The two-answer check is old and still worth running, not because the original headless bug is common now, but because the pattern it belongs to is how most of this works: ask the same question twice and see whether the answers match.
Short answers to the questions that lead here
Why do Notification.permission and navigator.permissions.query disagree? They reach the same state through different code, and some environments implement one path more completely than the other. Headless builds historically disagreed.
Is that still used to detect headless? The specific bug is largely fixed. The check costs two lines, so it is still run.
Should I grant all permissions in automation? No. It stops dialogs and produces a browser whose permission set almost nobody has, and on Firefox a camera grant disables WebRTC address protections.
What should the permission states be? Mostly prompt, with the few specific grants a real profile would have accumulated.
Do granted permissions persist? Context-level grants disappear with the context. Profile-level grants persist, including into future sessions on a reused profile.
Can a page enumerate my permissions? It can query the names it knows, which in practice is the same thing.
See also: what a persistent profile fixes and breaks, WebRTC leak with a proxy, and what sannysoft actually checks, whose permissions row is this check.
Sources
- MDN for
Permissions.query()andNotification.permission. - The long-standing headless detection suites that compare the two, where this check has lived for years.
- Firefox’s conditioning of
default_address_onlyandobfuscate_host_addresseson active-or-permitted capture, which is the mechanism behind the grant described above.
From the notes of invisible_playwright, a Firefox patched at the C++ level. The permission trap is in our notes as a gap with no patch, which is why this page tells you to audit rather than promising it is handled.