What it measures
Permissions: we probe the browser's Permissions API for the set of supported permissions and their reported states; engine-bound.
Each probe resolves to one of a small fixed set of outcomes covering the W3C-defined states plus two failure-mode buckets that distinguish 'the browser does not recognise this name at all' from 'the call threw at runtime'. The distinction matters because the former is stable across page loads on a given browser while the latter is typically transient. Both buckets are folded into the hash so the signal encodes browser-capability structure as well as user decisions.
as a tier-2 signal; callers must opt in with `tiers: [1, 2]`. The signal is engine-bound and origin-bound, so it is less stable across page loads than tier-1 hardware probes and contributes entropy to the composite fingerprint rather than serving as a standalone identifier.
How it's collected
The collector first checks for an iframe context. If it is running inside an iframe, it settles synchronously and fires zero queries. Otherwise it checks that `navigator.permissions.query` is available; if not, it settles as unsupported. For normal collection it issues a parallel batch of `navigator.permissions.query()` calls, with each per-query Promise catching its own rejection so the combined Promise never rejects.
`resolve()` awaits the batch, folds repeat probes per name into a single stable answer to absorb per-call noise, and hashes the resulting `name:state` pairs in a canonical order. The canonical order is independent of the order in which the names are declared internally, so future internal reordering does not shift the hash. The full list of probed names, the per-name repeat count, the fold rule, and the canonical-order algorithm are not part of the public contract.
The combined Promise overlaps with every other collector's Phase B work in the orchestrator. Total wall-clock contribution is typically 30 to 80 ms on Chromium, mostly hidden by concurrency.
// Success
{
value: {
count: number,
granted: string[],
prompt: string[],
denied: string[],
unsupported: string[],
threw: string[]
},
hash: string, // 16-char hex xxHash64
confidence: 'normal',
binding: 'engine',
timeMs: number,
}
// Absent (iframe / API missing / outer try-catch)
{
value: null,
hash: string, // sentinel absent-hash
confidence: 'absent',
binding: 'engine',
timeMs: number,
}Public result shape. The grouped-by-state output is for backend analytics readability; the hash itself is computed from `name:state` pairs in a canonical order and does not depend on this grouping.
Confidence rules
| Confidence | Trigger |
|---|---|
| normal | navigator.permissions.query is available, collector is not in an iframe, and the parallel probe batch resolved |
| absent | Collector self-detected an iframe context, so zero queries fired; sentinel: 'unsupported' |
| absent | navigator.permissions or navigator.permissions.query is missing (API not available in this runtime); sentinel: 'unsupported' |
| absent | Outer try/catch in dispatch or resolve fired (unexpected runtime failure); sentinel: 'threw' |
| stabilized | Caller passed stabilize: ['iframe']. The iframe rule set excludes permissions unconditionally, and the result is tagged 'stabilized' rather than 'absent' so consumers can tell the signal was excluded on purpose |
Why engine-bound
Permission state is decided per-origin and per-user-decision, not per-hardware. Chrome and Safari on the same machine on the same origin can legitimately return different states for the same permission if the user has granted access in one browser but not the other. Including permissions in `the hardware signal set` would shatter cross-browser stability for identical physical devices.
More fundamentally, the set of permission names a browser recognises is engine-defined. Each engine implements a different overlapping subset of the W3C registry, and unrecognised names are reported as structurally absent rather than as a runtime failure. The hash encodes engine capabilities as well as user decisions, so it is engine-bound at minimum and origin-bound in practice.
Things worth knowing
- The probes are fired in a single Promise.all in dispatch(), so they run in parallel and overlap with every other collector's Phase B work in the orchestrator. Total wall-clock contribution is typically 30 to 80 ms on Chromium, mostly hidden by concurrency.
- Adding or removing a probed permission name shifts the hash. Internal reordering of the declared name list does not shift the hash because hashing happens in a canonical order independent of declaration order.
- The 'structurally absent' bucket is stable across page loads on the same browser. It means the browser does not recognise the permission name at all, not that a query transiently failed. That distinction lets the hash encode browser capability structure.
- The signal is tier 2, so it does not run by default. Callers must opt in with tiers: [1, 2] in the Benny configuration. This reflects the signal's origin-bound and less-stable nature relative to tier-1 hardware and engine probes.
- The list of probed names, the per-name repeat count, the per-call fold rule, descriptor-shape quirks for individual permissions, and the canonical-order algorithm used before hashing are deliberately not published. Treat the hash and the grouped-by-state result shape as the stable interface; internals evolve between releases.
Last reviewed 2026-06-04

