Browser fingerprinting via extension detection
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:
-
Audit what you expose.
node tooling/analyze-extension.js <id>unpacks your CRX and lists every reachable resource and content script match pattern. -
Delete
web_accessible_resourcesentries you don’t need. Bundlers add them by default. Four extensions in the top 500 declare files they don’t even ship! -
Set
use_dynamic_url: trueon 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. -
Replace hardcoded
chrome-extension://<id>/pathstrings withchrome.runtime.getURL('path'). This is the only migration cost of step 3. -
Narrow
matchesto 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. -
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.
-
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.
-
Don’t write a stable vendor-named attribute on
<html>or<body>.data-gr-ext-installed(Grammarly) anddata-lt-installed(LanguageTool) are readable by any script on the page. -
Keep “already initialised” state in a
WeakSetin your isolated world rather than in the DOM. The page cannot reach it. -
Don’t give injected nodes stable ids, class names, or custom element tag names.
#smartyContainerandstyle#HighlightThisStylesare one-line detectors. -
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-vqzsuakfRoboForm looks like it does the same, but the token
zudZLWqgx2D1YKRAY8bWWuwwas byte-identical across four fresh installs and only a numeric id suffix rotated. It is now a one-line detector. -
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.