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:

PropertyValue
Webpack chunk IDchunk.905
Extension scan module75023
Bundle size~2.7 MB
FrameworkEmber.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:

SystemInternal NameFunction
APFC / DNAtriggerApfc, triggerDnaApfcEventDevice fingerprinting engine. Collects 48 browser characteristics.
AEDAedEvent, fetchExtensionsActive Extension Detection. Probes for known extensions using fetch().
SpectroscopySpectroscopyEvent, scanDOMForPrefixPassive 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 ID
  • file: A known file path inside that extension’s package, such as popup.html, icon.png, or manifest.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

MethodTechniqueWhat it catches
AEDfetch() against known resource pathsExtensions that are merely installed, even if they inject nothing into the current page
SpectroscopyFull DOM tree walkExtensions 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):

#FeatureWhat it collects
1webrtcLocal IP address via WebRTC
2enumerateDevicesConnected cameras, microphones, speakers
3–6appName, tsSeed, appVersion, appCodeNameBrowser identification strings
7locationPage URL components (protocol, hostname, port, origin, href, hash, pathname)
8javascriptsJavaScript engine characteristics
9–13platform, product, productSub, cpuClass, oscpuOS and CPU identification
14hardwareConcurrencyNumber of CPU cores
15deviceMemoryDevice RAM in GB
16–17vendor, vendorSubBrowser vendor strings
18languageBrowser language
19–20timezoneOffset, timezoneTimezone data
21userAgentFull user agent string
22webdriverWhether the browser is controlled by automation
23doNotTrackDo Not Track setting
24incognitoWhether the user is in private browsing mode
25–30Screen propertiesColor depth, pixel depth, pixel ratio, resolution, orientation, available resolution
31–35Storage detectionsessionStorage, localStorage, IndexedDB, addBehavior, openDatabase
36canvasCanvas fingerprint (rendered hidden element, hashed)
37webglWebGL renderer, vendor, extensions, 65+ parameter values
38signalsBrowser lie detection (spoofed OS, browser, resolution, language)
39touchSupportTouch screen capabilities
40networkInfoConnection type, downlink speed, round-trip time
41batteryBattery level, charging status, estimated discharge time
42audioAudioContext fingerprint (oscillator, compressor, analyser nodes)
43automationAutomation framework detection
44–45plugins, mimetypsBrowser plugin list and MIME types
46–47fonts, fontsFlashInstalled system fonts
48getFeaturesMeta-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):

FlagControls
pemberly.tracking.fireApfcEventDNA fingerprint collection
pemberly.tracking.human.integrationHUMAN Security integration
pemberly.tracking.dfp.integrationMerchant Pool fingerprinting
pemberly.tracking.recaptcha.v3reCAPTCHA v3 integration
pemberly.tracking.apfc.network.interceptorNetwork request interception
pemberly.web.ondemandOn-demand fingerprinting mode
sync.apfc.headersSync fingerprint via HTTP headers
sync.apfc.couchbaseSync fingerprint to Couchbase
fingerprinting.collection.skip.performance.marker.checkSkip 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.

  1. Open LinkedIn in Chrome.
  2. Open Developer Tools (F12).
  3. Go to the Network tab.
  4. Filter by .js and look for the largest JavaScript bundle (~2.7 MB), or search the page source for chunk.905.
  5. Open the file. Search for chrome-extension://. You will find the extension array in module 75023.
  6. Search for AedEvent and SpectroscopyEvent to find the tracking event triggers.
  7. Search for apfcDfPK to find the encryption logic.
  8. Search for li/track to find the telemetry endpoint.
  9. Watch the Network tab for POST requests to https://www.linkedin.com/li/track after 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

DateExtensions in the scan list
201738
2024~461
May 2025~1,000
December 20255,459
February 20266,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.

Last modified March 4, 2026