Skip to content

The navigator.permissions query inconsistency that exposes headless browsers

· 19 min read
Copyright: MIT
Two permission values stacked, denied above prompt, with an orange inequality sign between them

Ask a browser the same question twice and it should give you the same answer. That is the whole premise behind one of the oldest and most stubborn headless-detection signals still in use. You ask Chrome whether the page is allowed to show notifications. You ask it once through Notification.permission, the property the Notifications API has exposed since before any of this mattered. You ask it again through navigator.permissions.query({name: 'notifications'}), the newer general-purpose Permissions API. A browser with a human and a screen behind it returns two values that agree. Old headless Chrome returned two values that contradict each other, and that contradiction was enough.

The trick is appealing because it needs nothing exotic. No timing race, no GPU probe, no challenge round trip. Three lines of synchronous-looking JavaScript, run on the first paint, and you have a high-confidence read on whether the thing loading your page is the browser it claims to be. It survived the 2017 launch of headless Chrome, the 2023 rewrite that was supposed to make headless indistinguishable from headful, and a generation of stealth plugins that tried to paper over it and mostly made things worse. This post is about why a question with one correct answer kept producing two.

The route is this. First the two APIs and why they speak in different vocabularies, because the whole signal lives in the gap between their enums. Then the original contradiction in old headless Chrome and the exact shape of the check. Then what the 2023 --headless=new rewrite did and did not fix. Then the more interesting failure: how the stealth ecosystem patched the property and introduced a fresh, more specific tell in the process. Then the related permission-state inconsistencies that a detector reaches for once the obvious one is closed. The through-line is that consistency across two surfaces is a much harder thing to fake than any single value, and the permissions pair is the cleanest small example of that idea anyone has.

Two APIs, two vocabularies

The signal exists because two different specifications describe the same underlying permission with two different enums, and a real browser keeps them in sync while an emulated one does not necessarily.

The older of the two is the Notifications API. Its Notification.permission static property has returned one of three strings for as long as it has existed: "granted", "denied", and "default". MDN’s definition of "default" is precise and worth holding onto. The user decision is unknown, and in that case the application acts as if permission were "denied". So "default" is the never-decided state. The site has not been granted notifications and has not been explicitly blocked; nobody has answered the prompt yet. Critically, "default" is the value a fresh origin sees on a normal browser the first time it asks, before any interaction.

The newer one is the Permissions API, standardised by the W3C with Marcos Cáceres of Apple and Mike Taylor of Google as editors; the version current at this writing is the Working Draft dated October 6, 2025. It gives developers one way to read the status of many permissions instead of a different idiom per feature. You call navigator.permissions.query(descriptor), you get back a Promise that resolves to a PermissionStatus, and you read its state. The PermissionState enum also has three values, but they are not the same three: "granted", "denied", and "prompt". The spec is explicit that when a permission has not otherwise been decided, its default state is "prompt". The MDN gloss on "prompt" is the parallel to the Notifications API’s "default": the user has not given express permission, and a caller that tries to use the feature will either trigger a prompt or be denied.

So the two never-decided states are spelled differently. Notifications calls it "default". Permissions calls it "prompt". A correctly behaving browser, asked about notifications it has never decided on, answers "default" on the first API and "prompt" on the second, and those are the same answer wearing two costumes.

Notification.permission permissions.query().state "granted" "granted" "denied" "denied" "default" "prompt" same underlying state, two spellings. the never-decided row is the one that leaks *The two enums line up except in their names for the undecided state. A correct browser maps default to prompt; the contradiction appears when one API reports a decided state the other does not share.*

That mapping is the entire game. The detector does not care what the permission actually is. It cares whether the two surfaces describe a single coherent reality. As long as a browser is one program with one permission store, they will. The moment a value is synthesised on one surface without being threaded through to the other, they diverge, and divergence is the signal.

2017: the original contradiction

Headless Chromium shipped in Chrome 59 beta, announced May 2, 2017, for Mac and Linux. The pitch was modest and entirely legitimate: run Chrome without the chrome, for automated testing and for servers with no display to draw to. Old headless was a separate code path, an alternate browser implementation that happened to live in the same binary and shared less with the real browser than its name suggested. That separateness is why so many small behaviours leaked, and the permissions pair was one of the first to be catalogued.

Antoine Vastel, then a PhD student working on bot detection and later at DataDome, published the canonical write-up in January 2018, a follow-up to an earlier round of headless tests. The notifications check is the one everyone copied. Stripped to its essentials it reads like this:

navigator.permissions.query({ name: 'notifications' })
.then((permissionStatus) => {
if (Notification.permission === 'denied' &&
permissionStatus.state === 'prompt') {
// contradiction: this is headless Chrome
}
});

Read it against the enum mapping and the problem is obvious. Notification.permission reports "denied", a decided, negative state. The Permissions API reports "prompt", the never-decided state. There is no coherent world in which the same origin has both denied notifications and never decided on them. On a normal desktop Chrome, a fresh origin returns "default" from the first call, which maps to the "prompt" the second call returns, and the two agree. Old headless returned "denied" from Notification.permission. The Permissions surface still said "prompt". The pair contradicted itself, and the contradiction was deterministic.

The root of it is that old headless had no way to display a notification and no user to permit one, so the Notifications API behaved as though notifications were off. The permission store underneath was never updated to match. One surface reflected the capability of the environment. The other reflected the empty store. The detector read both and caught them disagreeing.

What made this signal so widely deployed was its cheapness. It runs client-side in milliseconds. It needs no network round trip and no challenge. It is two property reads and a comparison, and it returns a boolean that, in 2018, was very nearly free of false positives. A genuinely paranoid user who had blocked notifications globally produced "denied" on both surfaces and so read as consistent; the contradiction specifically required denied on one and the undecided state on the other, which is a combination a real browser does not normally produce on its own. The infosimples open-source detect-headless test suite, which still runs in browsers today, lists the permissions check alongside the webdriver flag, the missing window.chrome object, the empty plugins array, and the languages tell as one of its core probes. It was a member of the standard battery from the start.

read both surfaces, then compare real browser, fresh origin Notification.permission "default" query().state "prompt" agree → pass old headless Chrome Notification.permission "denied" query().state "prompt" contradict → flag *The check passes on agreement and flags on contradiction. Old headless produced a decided value on one surface and the undecided value on the other, which no ordinary browser does.*

This is the post that headless-detection write-ups point back to when they need a concrete example, and for good reason. The signal is small, it is verifiable in a console, and it shows the structure of every cross-surface consistency check that followed. Read the long tail of headless signals for the wider catalogue the permissions pair sits inside, and the headless-detection tour for how these probes get combined.

2023: what the rewrite fixed, and what it didn’t

The architectural cause of the contradiction was the separate headless code path. So the obvious fix was to delete the separate path, and that is roughly what Chrome did.

The new headless mode unified the headless and headful implementations. Rather than an alternate browser, headless became the real Chrome with its windows created but never shown. The Chromium docs describe it plainly: as of Chrome 112 the platform windows are created but not displayed, and all other functions, existing and future, are available with no limitations. The --headless=new alias arrived around Chrome 109; the old implementation was carved off into a standalone binary called chrome-headless-shell from Chrome 132 onward; and the bare --headless flag was migrated to point at the new mode in the same period. The intent was a headless browser that behaves like the browser, because it is the browser.

For the permissions pair specifically, this helps. When headless runs the same permission machinery as headful, a fresh origin reports "default" from Notification.permission and "prompt" from the Permissions API, and the two agree the way they do on a normal desktop. The naive 2018 check, the one that fires only on the exact denied/prompt contradiction, does not trip on a clean --headless=new instance the way it tripped on old headless. To that extent the rewrite closed the original hole.

But it is worth being careful about what was closed. The rewrite removed the architectural reason the two surfaces disagreed. It did not make the existence of the two surfaces stop mattering. A detector that learned the lesson of 2018 does not hard-code denied/prompt. It reads both surfaces, normalises the enums against each other, and flags any pair that cannot coexist on a real browser, whatever the specific values. New headless answers the original question correctly, but the question generalised. And the moment automation tooling starts spoofing values to look more human, the two surfaces become the easiest place in the entire fingerprint to catch a value that was synthesised on one and forgotten on the other. The patching is where the next generation of this signal lives, and it is more interesting than the original.

The stealth-plugin own goal

The most-used answer to the permissions tell was the navigator.permissions evasion bundled in puppeteer-extra-plugin-stealth, the open-source stealth bundle that wraps Puppeteer (and now Playwright via playwright-extra). Its job is to make the surfaces agree. The way it went about that produced a value no real Chrome ever returns, and so traded a generic signal for a specific one.

The module’s own description names the target: it exists to fix the inconsistent behaviour of Notification.permission and Permissions.prototype.query in headless Chrome. Its mechanism is a proxy over Permissions.prototype.query that special-cases the notifications descriptor, plus a getter override on Notification.permission. On a secure (HTTPS) origin it makes Notification.permission report "default" rather than the headless "denied", which is the right idea: align the Notifications surface to the never-decided value so it maps cleanly to the Permissions API’s "prompt". On an insecure origin it intercepts the query and returns a PermissionStatus with state: "denied", taking care to set the prototype with Object.setPrototypeOf(..., PermissionStatus.prototype) so the returned object passes a prototype check.

That last detail is the giveaway, and the bug report that found it is instructive. Filed against the plugin in January 2021 and tested on Chrome and Chromium versions 87 and 88, it showed the plugin returning, for navigator.permissions.query({name: 'notifications'}), a state of "default" where every real browser returns "prompt". The two outputs side by side:

puppeteer-stealth { state: "default", permission: "default" }
real Chrome/Chromium{ state: "prompt", permission: "default" }

Look at what happened. "default" is a perfectly valid value for Notification.permission. It is not a valid value for a PermissionStatus.state. The PermissionState enum only contains "granted", "denied", and "prompt". By making the two surfaces agree on the string, the patch leaked the "default" token across the enum boundary and onto a surface where it cannot legitimately appear. A detector no longer needs to find a contradiction between the two surfaces. It can ignore the second surface entirely and ask one question: does permissions.query ever return "default"? Real Chrome: never. Patched Puppeteer: yes. The fix converted a probabilistic, environment-dependent contradiction into a deterministic, plugin-specific fingerprint that points not at automation in general but at this exact tool.

permissions.query({name:'notifications'}).state real Chrome "prompt" a valid PermissionState patched Puppeteer "default" not in the PermissionState enum one read, no contradiction needed: the wrong value is the whole signal *The patch aligned the two surfaces on a string but moved the Notifications API's "default" onto a surface whose enum has no such value. A single read of one surface now identifies the tool.*

This is the general failure of override-based stealth, visible in miniature. A patch that changes one observable property in isolation tends to create a new observable: the patch itself. The Permissions API is an unusually good place to catch this because its enum is small and closed, so any token outside the three legal values is, by definition, synthetic. The plugin has been revised over the years and the specific "default" leak has been addressed in later versions, but the structural lesson held: there is a reason stealth plugins lose over time, and it is that each cosmetic fix adds a new edge. The more durable approaches change the value where the browser actually computes it rather than where script reads it, which is the source-patch versus runtime-injection divide. A value patched at the C++ permission store is consistent across every surface for free, because there is only one store; a value patched in JavaScript is consistent only across the surfaces the author remembered to touch.

There is a second-order tell here too, one that does not depend on the returned value at all. Overriding Permissions.prototype.query with a JavaScript function changes how the function presents itself. A native query reports function query() { [native code] } when stringified and lives at a particular place on the prototype chain. A replacement, however carefully wrapped in a Proxy, can leak through toString, through Function.prototype.toString checks, through the descriptor of the property on the prototype, or through subtle differences in how it throws on bad input. Detectors that have given up on the value check the shape of the function instead. This is the same class of probe used against any patched native, and it generalises far past permissions; it is why runtime injection leaves a trail that source patching does not.

What a detector reaches for next

Close the notifications hole and a determined detector does not give up on the API. It widens the query. The Permissions API exposes many descriptors, and each one is another place where an environment that does not really support a capability can be caught describing it incoherently.

The richest of these involve permissions a headless environment cannot meaningfully hold. Consider the push descriptor, {name: 'push', userVisibleOnly: true}, which is tied to notifications and to a service-worker registration. Consider camera and microphone, {name: 'camera'} and {name: 'microphone'}, which on a real machine depend on actual capture devices and on the browser’s media stack. A server with no camera, no microphone, and no notification delivery path can still return some state for each of these, and the question for the detector is whether the returned states form a profile a real device would produce. The exact internal field layout these descriptors map to is not publicly documented in a way I can cite precisely, and the per-descriptor behaviour has shifted across Chrome versions, so the honest description is that detectors compare the combination of states across many descriptors against a model of plausible real-device profiles rather than relying on any single magic value. A device that reports a coherent story across notifications, push, camera, microphone, geolocation, and clipboard is harder to dismiss than one whose answers only make sense if you assume nobody is home.

The clipboard descriptors deserve a note because they have a history of inconsistent support. clipboard-read and clipboard-write are Chrome-specific extensions to the standardised set, and querying a descriptor the running browser does not actually implement is supposed to reject the promise. Whether a given build rejects, resolves, or throws on an unusual or non-standard descriptor name is itself observable, and the pattern of rejections is a fingerprint of the build. A browser pretending to be a Chrome version it is not can be caught answering the wrong way to a descriptor that real version handles differently.

There is also the change-event surface. A PermissionStatus is an EventTarget; it fires change when the underlying state moves, and it exposes an onchange handler. The presence, type, and behaviour of that handler is another thing a patch has to get right and frequently does not. A naive override that returns a plain object with a state string but no working onchange, or an onchange that is a data property where the native is an accessor on the prototype, fails a check that never looks at the permission value at all.

query a battery of descriptors, judge the whole profile notifications push camera microphone geolocation clipboard-read coherent real-device profile? yes → pass no → flag no single value decides it; the combination has to be one a real machine could produce *Once the notifications pair is patched, the probe widens. The detector stops hunting for one magic value and starts asking whether the full set of permission states is internally consistent.*

The push toward profiles rather than single values is the same move every mature anti-bot signal makes. A single property is cheap to spoof and so cheap to defeat; a joint distribution across many properties is expensive to spoof correctly and so durable. The permissions surface happens to expose a clean, enumerable set of related properties, which makes it a natural place to run that kind of joint check. This is the JavaScript-runtime fingerprinting idea applied to one well-defined API, and it is why a fix that touches only {name: 'notifications'} buys less than it seems to.

A moving target underneath

One more thing complicates any snapshot of this signal: the real browser’s behaviour is itself drifting, which moves the line a detector has to draw.

Chrome has been quietly reshaping how notification permissions behave for ordinary users. In October 2025 it shipped automatic revocation of notification permissions for sites a user has granted but rarely engages with, on Android and desktop, citing the fact that fewer than one percent of all notifications get any interaction. That feature does not change the never-decided default state at the centre of the classic check, but it does mean the population of real browsers now contains more origins whose notification permission has been moved from granted back toward a revoked state by the browser itself rather than the user. A naive detector that assumes any non-default state must have been set by a human can mis-read that revocation. The space of legitimate permission profiles is wider in 2026 than it was in 2018, and the detector’s model of normal has to keep up.

That cuts both ways. A wider distribution of legitimate states gives automation a little more cover, because more values are plausibly human now. It also gives the detector more structure to model, because the rules governing when Chrome itself changes a permission are specific and a synthetic profile that ignores them stands out. The signal did not get weaker. It got more detailed.

Closing

The notifications contradiction is the smallest complete example of an idea that runs through every modern bot fingerprint. A single value is a thing you can set. Two values that have to agree are a relationship you have to maintain, and maintaining a relationship across surfaces is exactly the work an emulated environment skips. Old headless Chrome failed the check because its two surfaces were wired to two different facts about the same permission. The 2023 rewrite fixed that by making headless run the real permission store, so there is only one fact again. The stealth plugins failed worse than old headless did, because in trying to force agreement they leaked a Notifications-API string onto a Permissions-API surface whose enum forbids it, and turned a generic tell into a tool signature.

What is left in 2026 is not a single trick that catches a single browser. It is a method. Read more than one surface, normalise their vocabularies, and flag any joint state a real device could not produce. The notifications pair was the first clean instance of that method, and it remains the one to reach for when explaining why consistency is harder to fake than any value. Three lines of JavaScript, one permission, two questions. The bot is the thing that answers them differently.


Sources & further reading

Further reading