h. parry

Browser fingerprinting via extension detection

H. Parry · July 2026

Browser fingerprinting via extension detection

No web API returns a list of installed browser extensions. This is due to browsers taking a privacy friendly, anti fingerprinting stance. Browser extensions have to load content though, as well as interact with pages in predictable ways, so many are in fact uniquely detectable.

Of the 500 most-installed Chrome extensions, 328 of them can be identified from an ordinary web page. Code and per-extension evidence: chrome-extension-identifier.

1. There is no API for this

chrome.management.getAll() returns the installed set, but it is an extension API behind the management permission, so only another extension can call it. Page JavaScript has no equivalent. navigator.plugins, the old NPAPI enumeration vector, now returns a fixed list of PDF entries whatever you have installed (in fact, lacking this historically indicated use of headless chrome!)

In Chrome, content scripts run in an isolated world with their own JavaScript heap, so a page can’t read their variables or see them patch built-ins. And a request to a chrome-extension:// URL that isn’t permitted fails identically whether or not the extension exists (i.e. strong permissions stops probing).

So, though I’ve not done extensive testing, the Chrome sandbox appears to hold. Extensions detection must as a result be done by looking for signals that specific extensions uniquely expose.

2. Why an extension list is worth having

A fingerprint is a set of browser properties that have enough entropy to uniquely identify you, while keeping enough stability in that entropy so that you’ll have the same fingerprint in e.g. 24 hours when you revisit a website. It’s used for marketing (booo), bot protection (whoop), and plenty more.

A list of extensions is a strong target for a component of a fingerprint, since there is plenty of entropy, and most users rarely install of uninstall extensions. It’s also a somewhat strong signal for human-ness, since automated browsers don’t typically come with extensions pre-installed.

Detecting specific extensions could also be useful. Apollo.io is a B2B prospecting tool, so detecting it marks the visitor as a salesperson working the site rather than a customer. A site that relies on ads might want to politely ask an adblock user to turn it off.

3. How detection works

Probing a web-accessible resource (307 of the 328)

Extensions declare web_accessible_resources: files they are willing to serve to web pages, with a matches list of origins allowed to load them. Declare a stable filename to <all_urls> and any page can just ask for it:

const url = `chrome-extension://${extensionId}/${resourcePath}`;
const res = await fetch(url, { cache: 'no-store' });
return res.ok;   // true means this exact extension is installed

No false positives are possible: the extension ID is in the URL and only that extension can serve that file. It costs about 0.2ms hit or miss, because it is a local file lookup. (no-store matters, or a cached response outlives an uninstall.)

Adobe Acrobat, the most-installed extension in the store at ~312M users, exposes roughly 40 such files. The detector is one line:

run: () => probeFetch(ID, 'viewer.html'),   // its bundled PDF viewer

Because these are all just “does this path load”, they are generated from manifests rather than hand-written. About 1,500 ship that way.

Watching what the extension does (21 of the 328)

The rest expose nothing fetchable but modify pages visibly.

Some announce themselves pretty strongly! Grammarly writes a flag on the body so it doesn’t double-init, and you can just read it:

<body data-gr-ext-installed data-new-gr-c-s-check-loaded>

Others sign their work. Dark Reader exposes no reachable resource but labels its restyling in three places, all carrying the vendor name, so this identifies Dark Reader specifically, instead of just “generic dark mode extension”:

<html data-darkreader-mode="dynamic">
<style class="darkreader darkreader--sync">
<div data-darkreader-inline-bgcolor>

Other extensions need a bit of baiting, since an extension only acts when it sees something worth acting on. Insert a login form and watch a password manager react.

Behavioural signals are weaker and much slower. They only fire while the extension is doing something, so Dark Reader in light mode leaves no trace. And there is no “no” event to wait for, so a negative costs the full timeout:

Tier Definitions Wall clock Extensions identified
Resource probes 1,834 0.16s 307
Behavioural 21 12.0s 21

75x the time for 4.2% more install-weighted coverage. Realistically a tracker runs the fast tier and skips the rest, which is also the reproducible one.

4. If you build extensions

Detectability is set by the manifest, not by the code. The steps, in order:

  1. Audit what you expose. node tooling/analyze-extension.js <id> unpacks your CRX and lists every reachable resource and content script match pattern.

  2. Delete web_accessible_resources entries you don’t need. Bundlers add them by default. Four extensions in the top 500 declare files they don’t even ship!

  3. Set use_dynamic_url: true on every entry you keep. Chrome then serves the file under a per-session random token instead of your extension ID, so a probe fails identically whether or not you are installed. uBlock Origin Lite protects all 41 of its resources this way.

  4. Replace hardcoded chrome-extension://<id>/path strings with chrome.runtime.getURL('path'). This is the only migration cost of step 3.

  5. Narrow matches to the origins that need the file. Use [] if none do, which grants access to nobody (Shazam ships this way). Scoping to your own site drops the set of origins that can detect you from every site on the web to the ones you already integrate with.

  6. Don’t declare a broad content script unless you need one. No content script and no reachable resource means no page-side surface at all. This is the largest category of extension I could not detect.

  7. Don’t rely on rotating filenames per release. Apollo.io randomises all 21 of its exposed filenames every release and is still detected, via the DOM elements it injects.

  8. Don’t write a stable vendor-named attribute on <html> or <body>. data-gr-ext-installed (Grammarly) and data-lt-installed (LanguageTool) are readable by any script on the page.

  9. Keep “already initialised” state in a WeakSet in your isolated world rather than in the DOM. The page cannot reach it.

  10. Don’t give injected nodes stable ids, class names, or custom element tag names. #smartyContainer and style#HighlightThisStyles are one-line detectors.

  11. Generate those names per page load, not at build time. Bitwarden regenerates its injected tag names on every load:

    load 1   jv-f-wwrnmir      nooth-tyxp
    load 2   j-bxnq-mca        h-vqzsuakf
    

    RoboForm looks like it does the same, but the token zudZLWqgx2D1YKRAY8bWWuw was byte-identical across four fresh installs and only a numeric id suffix rotated. It is now a one-line detector.

  12. Inject nothing until the user invokes the feature, if the feature allows it. Dark Reader in light mode leaves no trace.

Steps 8 to 12 don’t apply if page-side detection is a feature you want. Grammarly’s flag lets sites cooperate with it, which is a reasonable trade.