The Attack: How it works
Every time you open LinkedIn in a Chrome-based browser, LinkedIn’s JavaScript executes a silent scan of your installed browser extensions. The scan probes for thousands of specific extensions by ID, collects the results, encrypts them, and transmits them to LinkedIn’s servers. The entire process happens in the background. There is no consent dialog, no notification, no mention of it in LinkedIn’s privacy policy.
This page documents exactly how the system works, with line references and code excerpts from LinkedIn’s production JavaScript bundle.
Source File
All code references on this page come from a single JavaScript bundle served to every LinkedIn visitor. The filename is a content hash that changes with each deployment (e.g. 5fdhwcppjcvqvxsawd8pg1n51.js), but the stable identifiers are:
| Property | Value |
|---|---|
| Webpack chunk ID | chunk.905 |
| Extension scan module | 75023 |
| Bundle size | ~2.7 MB |
| Framework | Ember.js (globalThis.webpackChunk_ember_auto_import_) |
The bundle is a Webpack package containing multiple modules. Three of those modules form the scanning system described below.
Line numbers referenced on this page are from the December 2025 version of the bundle. They may shift between deployments, but the code structures, string literals, and module exports remain searchable by keyword.
Architecture
LinkedIn’s extension detection consists of three cooperating systems inside the same JavaScript bundle:
| System | Internal Name | Function |
|---|---|---|
| APFC / DNA | triggerApfc, triggerDnaApfcEvent | Device fingerprinting engine. Collects 48 browser characteristics. |
| AED | AedEvent, fetchExtensions | Active Extension Detection. Probes for known extensions using fetch(). |
| Spectroscopy | SpectroscopyEvent, scanDOMForPrefix | Passive extension detection. Scans the DOM for evidence of extension activity. |
All three systems feed into the same telemetry pipeline: LinkedIn’s li/track endpoint.
The Extension List
At line 9571, character offset 443, inside Webpack module 75023, there is a hardcoded array:
const r = [
{id: "aaaeoelkococjpgngfokhbkkfiiegolp", file: "assets/index-COXueBxP.js"},
{id: "aabfjmnamlihmlicgeoogldnfaaklfon", file: "images/logo.svg"},
{id: "aacbpggdjcblgnmgjgpkpddliddineni", file: "sidebar.html"},
// ... thousands more entries ...
];
Each entry has two fields:
id: A 32-character Chrome Web Store extension IDfile: A known file path inside that extension’s package, such aspopup.html,icon.png, ormanifest.json
The file field is not incidental. Someone at LinkedIn has identified a specific internal resource for each extension that is declared as web-accessible. This is the probe target.
As of December 2025, the array contained 5,459 entries. By February 2026, it had grown to 6,167. The array alone occupies roughly 409,000 characters of source code.
Stage 1: Active Extension Detection (AED)
AED is a brute-force scan. It attempts to load a known file from each extension using the fetch() API.
Chrome extensions can expose internal files to web pages through the web_accessible_resources field in their manifest.json. When an extension is installed and has exposed a resource, a fetch() request to chrome-extension://{id}/{file} will succeed. When the extension is not installed, Chrome blocks the request and the promise rejects.
LinkedIn tests every extension in the list this way.
Method 1: Parallel batch scan
Lines 9573 to 9576:
async function c() {
const e = [],
t = r.map(({id: t, file: n}) => {
return fetch(`chrome-extension://${t}/${n}`)
});
(await Promise.allSettled(t)).forEach((t, n) => {
if ("fulfilled" === t.status && void 0 !== t.value) {
const t = r[n];
t && e.push(t.id);
}
});
return e;
}
This fires all 6,167 fetch() requests simultaneously using Promise.allSettled(). Every request that resolves as "fulfilled" means that extension is installed. The function returns an array of detected extension IDs.
Method 2: Staggered sequential scan
Lines 9578 to 9579:
async function(e) {
const t = [];
for (const {id: n, file: i} of r) {
try {
await fetch(`chrome-extension://${n}/${i}`) && t.push(n);
} catch(e) {}
e > 0 && await new Promise(t => setTimeout(t, e));
}
return t;
}
This alternative probes extensions one at a time with a configurable delay between each request. Failed fetches are silently caught and discarded. The delay parameter (staggerDetectionMs) allows LinkedIn to throttle the scan, reducing its visibility in network monitoring tools and lowering CPU impact.
Which method runs
Lines 9577 to 9579:
const {
useRequestIdleCallback: i = false,
timeout: o = 2000,
staggerDetectionMs: l = 0
} = n;
const d = async () => {
const n = l > 0
? await staggeredScan(l) // Method 2
: await c(); // Method 1
// ... fire tracking events ...
};
i && "function" == typeof window.requestIdleCallback
? window.requestIdleCallback(d, {timeout: o})
: await d();
If staggerDetectionMs is greater than zero, LinkedIn uses the slower sequential scan. Otherwise it fires the parallel batch. The scan can also be deferred to requestIdleCallback, which delays execution until the browser is idle. The user sees no performance impact. The scan leaves no visible trace.
Stage 2: Passive DOM Scanning (Spectroscopy)
Independent of the AED scan, LinkedIn runs a second detection system that walks the entire DOM tree looking for evidence of extension activity.
Many Chrome extensions inject elements into web pages: modified HTML, added scripts, altered attributes, UI overlays. When they do, the injected content often contains references to the extension’s internal URL scheme (chrome-extension://). Spectroscopy finds these references.
Implementation
Lines 9581 to 9587:
const d = "chrome-extension://";
function p(e, t, n) {
// Scan text nodes
if (e.nodeType === Node.TEXT_NODE
&& void 0 !== e.textContent
&& e.textContent.includes(t))
n.push(u(e.textContent));
// Scan element attributes
if (e.nodeType === Node.ELEMENT_NODE)
for (let i = 0; i < e.attributes.length; i++) {
const o = e.attributes.item(i);
void 0 !== o.value && o.value.includes(t) && n.push(u(o.value));
}
// Recurse into child nodes
for (let i = 0; i < e.childNodes.length; i++)
p(e.childNodes[i], t, n);
}
The function p starts at the document root and recursively inspects every node. For text nodes, it checks whether the text contains chrome-extension://. For element nodes, it checks every attribute value. When it finds a match, it extracts the 32-character extension ID from the URL.
The results are fired as a SpectroscopyEvent:
Array.isArray(n) && n.length > 0
&& e.fireTrackingPayload("SpectroscopyEvent", {browserExtensionIds: n, ...t});
Why two detection methods
| Method | Technique | What it catches |
|---|---|---|
| AED | fetch() against known resource paths | Extensions that are merely installed, even if they inject nothing into the current page |
| Spectroscopy | Full DOM tree walk | Extensions that actively modify the page, even if they are not in LinkedIn’s hardcoded list |
AED requires a pre-built target list but can detect passive extensions. Spectroscopy requires no list but only catches extensions that leave traces in the DOM. Together, they cover both cases.
Data Transmission
Detected extension IDs are sent to LinkedIn’s servers through two tracking events.
AedEvent (active scan results)
Line 9580:
Array.isArray(n) && n.length > 0
&& e.fireTrackingPayload("AedEvent", {browserExtensionIds: n, ...t});
SpectroscopyEvent (passive scan results)
Line 9587:
Array.isArray(n) && n.length > 0
&& e.fireTrackingPayload("SpectroscopyEvent", {browserExtensionIds: n, ...t});
Both events carry a browserExtensionIds array containing the Chrome extension IDs detected on that user’s browser. Both feed into the same telemetry transport.
The telemetry pipeline
The fireTrackingPayload method sends data to LinkedIn’s li/track endpoint:
https://www.linkedin.com/li/track
This endpoint is referenced at lines 2026, 4288, and 11785. The transport layer batches up to 29 events per request (line 2098), retries up to 4 times on failure, and supports LZ-based compression before transmission (function compressToBase64, lines 9587 to 9601).
Encryption
Before transmission, the fingerprint payload (which includes the extension scan results) is encrypted.
Line 9528:
f.encryptWithKeyFromDifferentSources(
JSON.stringify(t),
"apfcDfPK", // Public key identifier
"apfcDfPKV", // Public key version
n, r
).then(t => {
globalThis.apfcDf = t;
// ... transmit to telemetry endpoint ...
});
The payload is serialized to JSON, encrypted using an RSA public key identified as apfcDfPK, and stored on globalThis.apfcDf. From there, it is transmitted to two endpoints:
/platform-telemetry/li/apfcDf/apfc/collect
The encrypted fingerprint is also injected as an HTTP header into subsequent API requests made during the user’s session (via SyncCollectionHandler, line 9525). This means the fingerprint does not get sent once. It accompanies every API call the user makes for the duration of their visit.
Browser Targeting
Lines 9572 to 9577:
function a() {
return "undefined" != typeof window
&& window
&& "node" !== window.appEnvironment;
}
function s() {
return window?.navigator?.userAgent?.indexOf("Chrome") > -1;
}
if (!a() || !s()) return;
The extension scan runs only in Chrome-based browsers. The isUserAgentChrome() function checks for “Chrome” in the user agent string. The isBrowser() function excludes server-side rendering environments. If either check fails, the scan does not execute.
This means every user visiting LinkedIn with Chrome, Edge, Brave, Opera, Arc, or any other Chromium-based browser is subject to the scan.
The Larger Fingerprinting System: APFC
The extension scan is one component of a broader device fingerprinting system called APFC (Anti-fraud Platform Features Collection), internally also referred to as DNA (Device Network Analysis).
APFC collects 48 distinct browser characteristics (line 2260):
| # | Feature | What it collects |
|---|---|---|
| 1 | webrtc | Local IP address via WebRTC |
| 2 | enumerateDevices | Connected cameras, microphones, speakers |
| 3–6 | appName, tsSeed, appVersion, appCodeName | Browser identification strings |
| 7 | location | Page URL components (protocol, hostname, port, origin, href, hash, pathname) |
| 8 | javascripts | JavaScript engine characteristics |
| 9–13 | platform, product, productSub, cpuClass, oscpu | OS and CPU identification |
| 14 | hardwareConcurrency | Number of CPU cores |
| 15 | deviceMemory | Device RAM in GB |
| 16–17 | vendor, vendorSub | Browser vendor strings |
| 18 | language | Browser language |
| 19–20 | timezoneOffset, timezone | Timezone data |
| 21 | userAgent | Full user agent string |
| 22 | webdriver | Whether the browser is controlled by automation |
| 23 | doNotTrack | Do Not Track setting |
| 24 | incognito | Whether the user is in private browsing mode |
| 25–30 | Screen properties | Color depth, pixel depth, pixel ratio, resolution, orientation, available resolution |
| 31–35 | Storage detection | sessionStorage, localStorage, IndexedDB, addBehavior, openDatabase |
| 36 | canvas | Canvas fingerprint (rendered hidden element, hashed) |
| 37 | webgl | WebGL renderer, vendor, extensions, 65+ parameter values |
| 38 | signals | Browser lie detection (spoofed OS, browser, resolution, language) |
| 39 | touchSupport | Touch screen capabilities |
| 40 | networkInfo | Connection type, downlink speed, round-trip time |
| 41 | battery | Battery level, charging status, estimated discharge time |
| 42 | audio | AudioContext fingerprint (oscillator, compressor, analyser nodes) |
| 43 | automation | Automation framework detection |
| 44–45 | plugins, mimetyps | Browser plugin list and MIME types |
| 46–47 | fonts, fontsFlash | Installed system fonts |
| 48 | getFeatures | Meta-feature: the collection process itself |
Feature #23 is worth noting in isolation. LinkedIn collects the user’s Do Not Track preference, then excludes it from the fingerprint hash (line 9512, excludes: { doNotTrack: true }). They record that you asked not to be tracked. Then they track you.
Anti-Detection Design
Several implementation choices reveal that this system was designed to avoid detection:
Idle execution. The scan can be deferred to requestIdleCallback, which runs the code only when the browser has no other work to do. The user sees no performance degradation, no spinning indicator, no delay.
Staggered probing. The sequential scan mode introduces a configurable delay between each fetch() request, spreading thousands of network requests over time instead of firing them in a single burst that might appear in developer tools or network monitors.
Hidden iframe. The HUMAN Security (PerimeterX) integration loads a hidden iframe from li.protechts.net that is 0 by 0 pixels, positioned at left: -9999px, and marked aria-hidden="true" (lines 9536 to 9552).
Silent error handling. Failed fetch() requests are caught with empty catch blocks. No errors are logged to the console. No warnings are shown.
RSA encryption. The fingerprint payload is encrypted before transmission. Even if a user inspects the network request in developer tools, the payload contents are not readable.
Third-Party Integrations
The APFC system also feeds data to three external services:
HUMAN Security (formerly PerimeterX)
Lines 9536 to 9552:
c = "https://li.protechts.net/index.html?ts=" + r
+ "&r_id=" + encodeURIComponent(o)
+ "&app_id=" + a + "&uc=scraping"
LinkedIn loads a hidden iframe from li.protechts.net, passing a timestamp, the page’s tree ID, a hashed session cookie (bcookie), and the app ID (PXdOjV695v in production). The iframe reads and sets PerimeterX cookies (_px3, _pxhd, _pxvid, pxcts) via cross-origin postMessage.
Merchant Pool (Device Fingerprinting)
Lines 9529 to 9535:
const n = `https://merchantpool1.linkedin.com/mdt.js
?session_id=${bcookie}&instanceId=${instanceId}`
A separate fingerprinting script is loaded from merchantpool1.linkedin.com, passing the user’s session cookie and a hardcoded instance ID (fb6bbd47-fa7c-4264-b4e9-b25948407586).
Google reCAPTCHA v3 Enterprise
Lines 9553 to 9560. LinkedIn loads https://www.google.com/recaptcha/enterprise.js and executes it on page load with action "onPageLoad", collecting the resulting token.
Feature Flags
The fingerprinting and scanning systems are controlled by LinkedIn’s internal experimentation platform, LIX (LinkedIn Internal eXperimentation):
| Flag | Controls |
|---|---|
pemberly.tracking.fireApfcEvent | DNA fingerprint collection |
pemberly.tracking.human.integration | HUMAN Security integration |
pemberly.tracking.dfp.integration | Merchant Pool fingerprinting |
pemberly.tracking.recaptcha.v3 | reCAPTCHA v3 integration |
pemberly.tracking.apfc.network.interceptor | Network request interception |
pemberly.web.ondemand | On-demand fingerprinting mode |
sync.apfc.headers | Sync fingerprint via HTTP headers |
sync.apfc.couchbase | Sync fingerprint to Couchbase |
fingerprinting.collection.skip.performance.marker.check | Skip performance marker check |
These flags allow LinkedIn to enable or disable fingerprinting for specific user segments, run A/B tests on scanning behavior, and roll out changes incrementally. The existence of A/B testing flags for a surveillance system means LinkedIn is actively experimenting with how to scan users more effectively.
Data Flow Summary
1. User opens LinkedIn in a Chrome-based browser
│
▼
2. Webpack loads chunk.905 (~2.7 MB)
│
├──► APFC/DNA engine initializes
│ Collects 48 browser fingerprinting features
│ (canvas, WebGL, audio, fonts, hardware, network, battery...)
│
├──► AED: fetchExtensions()
│ Fires up to 6,167 fetch() requests to chrome-extension:// URLs
│ Collects IDs of every installed extension that responds
│ Fires AedEvent with browserExtensionIds[]
│
├──► Spectroscopy: scanDOMForPrefix()
│ Walks the entire DOM tree
│ Searches every text node and attribute for "chrome-extension://"
│ Fires SpectroscopyEvent with browserExtensionIds[]
│
├──► HUMAN Security iframe (li.protechts.net, hidden, 0×0 px)
├──► Merchant Pool script (merchantpool1.linkedin.com)
└──► reCAPTCHA v3 Enterprise
│
▼
3. All data encrypted with RSA public key (apfcDfPK)
│
▼
4. Transmitted to:
https://www.linkedin.com/li/track
/platform-telemetry/li/apfcDf
/apfc/collect
│
▼
5. Encrypted fingerprint stored on globalThis.apfcDf
Injected as HTTP header into every subsequent API request
LinkedIn receives the fingerprint with every action you take
How to Verify This Yourself
All of the above can be verified independently.
- Open LinkedIn in Chrome.
- Open Developer Tools (F12).
- Go to the Network tab.
- Filter by
.jsand look for the largest JavaScript bundle (~2.7 MB), or search the page source forchunk.905. - Open the file. Search for
chrome-extension://. You will find the extension array in module75023. - Search for
AedEventandSpectroscopyEventto find the tracking event triggers. - Search for
apfcDfPKto find the encryption logic. - Search for
li/trackto find the telemetry endpoint. - Watch the Network tab for POST requests to
https://www.linkedin.com/li/trackafter the page loads.
The code is minified and partially obfuscated, but the string literals, endpoint URLs, module exports, and control flow are preserved. Everything documented on this page can be read directly from the source.
Growth Rate
| Date | Extensions in the scan list |
|---|---|
| 2017 | 38 |
| 2024 | ~461 |
| May 2025 | ~1,000 |
| December 2025 | 5,459 |
| February 2026 | 6,167 |
LinkedIn added 708 extensions to the scan list between December 2025 and February 2026. That is roughly 12 new extensions per day. The system is not static. It is actively maintained and expanding.
All line numbers and code excerpts reference LinkedIn’s production JavaScript bundle (Webpack chunk.905, module 75023) as served to Chrome users. The filename is a content hash that changes with each deployment. The file is minified, but variable names in module exports, string literals, endpoint URLs, and control flow structures are sufficiently preserved to reconstruct the system described above.