A Chrome/Edge/Brave extension that watches every page you visit and tells you — in real time — which scripts are fingerprinting your browser, how aggressively, and what techniques they use.
FP Radar is a pure observer. It does not spoof, block, or randomize any value the page reads. It only instruments the JavaScript APIs browsers expose for fingerprinting, records every access with a stack-trace attribution, and shows you the result.
Use it to:
- see at a glance whether a site is fingerprinting you,
- know which third-party script is doing it (by URL),
- learn what a healthy site's JS looks like vs. an anti-bot-protected one,
- export a JSON report for bug bounties, research, or GDPR/DPIA evidence.
I built FP Radar to see the invisible half of web tracking. Cookies and URL trackers are well-understood problems with well-known solutions — clear cookies, install a blocker. Browser fingerprinting is the half nobody shows you. This project exists to learn how it works by watching it happen, live, on every page I visit.
Fingerprinting is a passive identification technique. A website asks your browser a series of ordinary-looking questions:
- How big is your screen? What is your timezone?
- Which fonts do you have installed?
- How does your GPU render a triangle? An emoji? A curve of text?
- How does your sound card's digital-signal-processing chain compress a synthesised tone?
- How many CPU cores, how much RAM, which language preferences, which browser plugins, which exact build of Chrome?
None of those answers is sensitive on its own. Combined, they form a pattern that is stable across sessions and usually unique to your machine. Unlike a cookie, you cannot delete it: your browser is the fingerprint. A modern tracker uses 20–40 such signals yielding roughly 25 bits of entropy on desktop browsers — enough to single out one user in a population of 30 million. Field measurements consistently report 80–90 % of real-world fingerprints as globally unique.
- GDPR / ePrivacy consent. In the EU, fingerprinting is user tracking and requires explicit consent. In practice the majority of sites fire fingerprinting scripts before you see the cookie banner.
- Anti-fraud vs. tracking overlap. The same APIs that protect a bank login from stolen session tokens are the ones that identify you across every site that shares an ad network. From the browser's point of view the two uses are indistinguishable.
- Privacy tools can backfire. Extensions that spoof or randomize canvas and WebGL output can make you more identifiable — your deliberately-wrong values are themselves a fingerprint. Radar is detection-only for exactly this reason.
Install Radar, pin it, and browse normally for a few days. Typical lessons:
- Which third-party domains fingerprint you most often (usually Google, Meta, Akamai, LiveRamp, Shape Security / F5, hCaptcha, PerimeterX / HUMAN…).
- A "static blog" often isn't: a single third-party comment widget can light up eight categories at once.
- Consent banners are overwhelmingly decorative — the tracking has already happened before you click accept.
- A single first-party anti-fraud SDK on a bank or airline login can produce more fingerprinting traffic than every ad-tech tracker on a tabloid homepage combined.
The point of the tool isn't the badge number. It's building the mental model: once you've watched this happen a few dozen times, you'll read any site's privacy policy — and any privacy-tool's marketing copy — much more skeptically.
- Not a blocker. It never modifies the values a page reads. If you want blocking/spoofing, pair Radar with CanvasBlocker, Chameleon, or a hardened browser like Brave or the Tor Browser.
- Not an ad blocker. It ignores network-level tracking (beacons, pixels, cookies) and focuses exclusively on JavaScript-level fingerprinting.
- Not an anti-bot bypass. Radar is for humans who want to understand what's being done to them, not for scrapers trying to look like humans.
- Screenshots
- Install
- Verify it works
- Quick tour
- Detection catalog
- Tracker attribution (EasyPrivacy + PSL)
- Scoring model
- Architecture
- Privacy
- Development
- Comparison to other extensions
- Roadmap
- License
Badge on the toolbar shows a live hit counter for the active tab. The colour of the badge maps to the risk bucket (blue → amber → red).
The popup lists every fingerprinting category that fired, sorted by intensity, with the exact APIs touched. A second tab lists the offending scripts by URL with the techniques attributed to each one.
The dashboard (open from the popup or from chrome://extensions) shows a
chronological history of every tab you've opened, statistics on the most
aggressive trackers, a whitelist manager, and per-category toggles.
FP Radar targets Manifest V3 and works on Chrome 111+,
Microsoft Edge 111+, Brave, Opera and any Chromium-based browser that
supports MV3 content scripts with world: "MAIN".
- Clone this repo.
- Run
python3 scripts/generate_icons.pyto generate the toolbar icons. - Open
chrome://extensions. - Enable Developer mode (top-right).
- Click Load unpacked and pick the repository root.
- Pin the FP Radar icon to your toolbar.
No build step is required — the extension is plain ES modules and content scripts. There is no bundler, no transpiler, no network dependency.
Firefox does not yet support world: "MAIN" in content_scripts the same
way Chromium does. A Firefox port is tracked in
#1.
After loading the unpacked extension, run through this 2-minute smoke test. Each step builds on the previous one — if any step fails, the one before it tells you where the break is.
-
Open
chrome://extensions. -
Find FP Radar 1.1.0. Under Inspect views click service worker to open DevTools on the background script.
-
In the worker Console there should be no red errors (yellow warnings are fine).
-
In the Console, paste this one-liner and press enter:
(async () => console.log(await chrome.storage.local.get()))()
Within 1–2 seconds the output should contain
fpradar.trackerswith ahostsarray of roughly 14,000 – 17,000 entries. That is the EasyPrivacy list cached on disk.
-
Right-click the toolbar icon → Options (or click Dashboard ↗ from the popup).
-
Open the Tracker list tab on the dashboard. You should see:
EasyPrivacy loaded ~16,000 domain rules https://easylist.to/easylist/easyprivacy.txt Last updated: just nowIf it says "Not loaded yet", click Refresh now. Still nothing? Check the worker Console for a
fetcherror (usually a corporate proxy blockingeasylist.to).
- In a new tab, open https://fingerprintjs.github.io/fingerprintjs/.
- Within ~2 seconds the toolbar badge should turn red with a number in the 120–300 range.
- Click the badge:
- Categories tab —
canvas_2d,webgl,audio,fonts,navigator,plugins,screen,match_mediaall present. - Domains tab — one row for
fingerprintjs.github.io. The fact thatgithub.io(a public suffix) resolves tofingerprintjs.github.iois the proof that the PSL is wired up. - Scripts tab — one or two concrete script URLs.
- Categories tab —
-
Open a news site that loads trackers, e.g. https://edition.cnn.com or https://www.theguardian.com.
-
Click the badge → Domains tab. You should see rows like:
doubleclick.net · 2 scripts · known tracker google-analytics.com · 1 script · known tracker scorecardresearch.com · 1 script · known tracker (the site itself) · 3 scripts
The red known tracker pill next to a domain is the end-to-end
proof both 1.1.0 features are working: the script's hostname was
collapsed via the Public Suffix List to its registrable domain and
matched against the EasyPrivacy set.
On a heavy-tracker page the popup's score number is slightly higher than
the sum of individual category weights would predict — that is the
× (1 + 0.3 · trackerFraction) bonus. Both numbers are exposed by
fpradar/getReport; paste this in the worker Console while the target
tab is focused:
(async () => {
const [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
console.log(await chrome.runtime.sendMessage({ type: "fpradar/getReport", tabId: tab.id }));
})()Look for baseScore vs score in the response. On a clean site they'll
be equal; on a site dominated by EasyPrivacy-listed trackers score
will be up to 30 % higher.
| Symptom | Likely cause |
|---|---|
| Badge never appears | Extension not pinned, or site is a chrome:// / file:// URL (not matched by <all_urls>). |
| Badge stays empty on fingerprintjs.github.io | MAIN-world injection failed — check the page's Console for errors, and the worker Console for CSP warnings. |
| Dashboard says "Not loaded yet" after Refresh | Network to easylist.to is blocked. The extension still works without the tracker list, just without the known tracker badge. |
Some third-party scripts lack a known tracker badge |
They aren't in EasyPrivacy. Not every tracker is listed — that's a filter-list problem, not a Radar bug. |
eTLD+1 shows something weird like com |
The script URL was malformed or used an IP address. IPs are returned verbatim. |
Open the extension and visit a few pages:
| Try | What you'll see |
|---|---|
https://fingerprintjs.github.io/fingerprintjs/ |
Dozens of hits across Canvas, WebGL, Audio, Fonts, Navigator — severe risk. |
https://amiunique.org/fingerprint |
Large navigator burst + Canvas + WebGL — high risk. |
A static blog, docs site, or your local index.html |
Usually zero hits — badge stays empty. |
| A big-bank login page | Canvas + AudioContext + ClientRects — anti-fraud fingerprinting (expected). |
| A news site with consent wall | Dozens of third-party scripts fingerprinting you before you consent. |
Click the badge to see the breakdown. Click the dashboard link to see the history of every origin that has fingerprinted you so far in this browser.
FP Radar monitors 20 categories of fingerprinting technique, covering the full surface documented in the 2026 literature (FPMON, CreepJS, FingerprintJS v4/v5, Octo Browser research, and Chrome's 142 Canary canvas-readback warnings).
| Category | What it observes | Weight |
|---|---|---|
canvas_2d |
toDataURL, toBlob, getImageData, OffscreenCanvas.convertToBlob |
12 |
webgl |
getParameter, getSupportedExtensions, getExtension, readPixels, getShaderPrecisionFormat (WebGL1 & 2) |
12 |
webrtc |
RTCPeerConnection.createOffer, createDataChannel, setLocalDescription |
12 |
audio |
OfflineAudioContext.startRendering, oscillator / compressor / analyser construction |
10 |
webgpu |
GPU.requestAdapter, GPUAdapter.info, requestAdapterInfo |
10 |
ua_data |
NavigatorUAData.getHighEntropyValues() |
9 |
fonts |
FontFaceSet.check, CanvasRenderingContext2D.measureText, isPointInPath |
8 |
speech |
speechSynthesis.getVoices |
8 |
media_devices |
navigator.mediaDevices.enumerateDevices |
6 |
battery |
Navigator.getBattery |
6 |
client_rects |
Element.getClientRects, getBoundingClientRect, Range.* variants |
6 |
sensors |
Accelerometer / Gyroscope / Magnetometer construction, deviceorientation / devicemotion listeners |
5 |
permissions |
navigator.permissions.query |
5 |
plugins |
navigator.plugins, navigator.mimeTypes |
4 |
match_media |
window.matchMedia |
4 |
timezone |
Intl.DateTimeFormat().resolvedOptions(), Date.getTimezoneOffset() |
4 |
navigator |
userAgent, platform, vendor, languages, hardwareConcurrency, deviceMemory, maxTouchPoints, … |
3 |
connection |
navigator.connection |
3 |
storage |
navigator.storage.estimate |
3 |
screen |
screen.width/height/availWidth/availHeight/colorDepth/pixelDepth, window.devicePixelRatio |
2 |
See docs/DETECTION.md for the full hook-by-hook
breakdown, the rationale behind each weight, and how to add your own
category.
Detecting that toDataURL was called is half the job. Knowing who
called it is the other half. FP Radar pairs its MAIN-world
hooks with two well-known open datasets:
A bundled snapshot of Mozilla's Public Suffix List (~10,200 rules, ~145 KB) lets Radar collapse every detected script URL to its registrable domain:
ad.doubleclick.net
stats.g.doubleclick.net ──► doubleclick.net
googleads.g.doubleclick.net
foo.bar.github.io ──► bar.github.io (github.io is a public suffix)
myapp.c.vercel.app ──► c.vercel.app (*.vercel.app is wildcarded)
This shows up as the Domains tab in the popup and as a new Top
tracker domains card on the dashboard. Regenerate the bundled PSL at any
time with python3 scripts/build_psl.py.
On first install (and every 24 h thereafter) the service worker
one-way-GETs https://easylist.to/easylist/easyprivacy.txt, parses the
plain ||domain^ rules into a compact Set (~16k entries, persisted in
chrome.storage.local), and flags every matched script with a
known tracker badge in the popup.
![sketch] Domains panel showing doubleclick.net and googletagmanager.com badged "known tracker", and a regular third-party badged "(unknown)".
Known trackers also contribute a risk-score bonus: up to +30 % when every hit in a tab comes from a confirmed tracker. A page that sits at a borderline moderate score (say 24) will tip into high when we can prove its fingerprinting is being driven by an EasyPrivacy-listed domain.
The Tracker list card on the dashboard shows the last-updated time, the rule count, and a Refresh now button for manual updates.
Every category has a weight and a burst threshold. For each tab the score is
base = Σ_c weight_c · ln(1 + hits_c / burst_c)
tracker = fraction of hits from EasyPrivacy-listed scripts ∈ [0, 1]
score = base · (1 + 0.3 · tracker)
- The
log1pkeeps a loop-happy script from eclipsing everything else — a tracker callingtoDataURL500 times scores only ~4× a tracker that calls it once. burstmodels natural usage:getBoundingClientRectis legitimate in small counts, so its burst is 30 and its weight 6; a singlereadPixels()is already highly suspicious, so its burst is 1 and its weight 12.- The total is bucketed into
clean / low / moderate / high / severebands, which drive the badge colour and the risk pill.
The weights were calibrated against a corpus of ~40 real sites (banks, ad
networks, newsrooms, bot-protected APIs, static blogs) so that the buckets
line up with human judgement; you can tweak them in
src/lib/detectors.js.
┌────────────────────────────────────────────────────────┐
│ page (MAIN world) │
│ │
│ site scripts ──calls──▶ wrapped DOM APIs │
│ │ │
│ ▼ │
│ inject.js (observer) │
│ │ │
│ ▼ │
│ window.dispatchEvent("__fpradar_hit__") │
└──────────────────────────────┬─────────────────────────┘
│
┌──────────────────────────────▼─────────────────────────┐
│ ISOLATED world │
│ │
│ content.js ── chrome.runtime.sendMessage ──▶ │
└──────────────────────────────┬─────────────────────────┘
│
┌──────────────────────────────▼─────────────────────────┐
│ service worker (background.js) │
│ │
│ aggregates per-tab hits │
│ updates the toolbar badge │
│ persists history to chrome.storage.local │
│ serves reports to popup / dashboard │
└────────────────────────────────────────────────────────┘
Key properties:
- Document-start, main-world injection. Using MV3's
world: "MAIN"content-script mode,inject.jsrewrites the descriptors of the target APIs before any page script executes. There is no<script>injection race. - Wrappers are observers, not filters. Every wrapper calls the original
function / getter and returns its real value.
toStringis preserved soFunction.prototype.toString.call(fn)still returns native-code. - Stack-trace attribution. At hook time we throw a cheap
Errorand scan itsstackfor the first non-chrome-extension://HTTPS URL — that is recorded as the offending script. - Batched delivery. Hits are queued in the MAIN world and flushed on a 50 ms timer (or immediately at 50 entries), then batched again in the ISOLATED world on a 200 ms timer. A typical page sends 2–6 messages to the service worker per second, regardless of how busy the tracker is.
- In-memory per tab, persistent across sessions. Live tab state lives
in the service worker's memory; on tab close or origin change the session
is archived to
chrome.storage.local(capped, FIFO).
See docs/ARCHITECTURE.md for diagrams and the
full message protocol.
FP Radar never sends data off-device. There is no telemetry, no analytics, no remote code.
- Storage:
chrome.storage.localonly. Cleared when the user removes the extension. - Permissions:
storage,tabs,activeTab, plus<all_urls>host permission — required because fingerprinting can happen on any origin. - No external network calls are made by the extension itself. The only network traffic you'll see in DevTools comes from pages you visit.
See docs/PRIVACY.md for the full data-flow audit.
.
├── manifest.json # MV3 manifest
├── src/
│ ├── inject.js # MAIN-world hook script (no imports — content-script)
│ ├── content.js # ISOLATED-world bridge (no imports)
│ ├── background.js # service worker (ES module)
│ ├── lib/
│ │ ├── detectors.js # category catalog, scoring
│ │ ├── trackers.js # EasyPrivacy fetch/parse/lookup
│ │ └── public_suffix_list.js # generated PSL + getRegistrableDomain()
│ ├── popup/ # toolbar popup
│ └── options/ # dashboard page
├── icons/ # generated PNG icons
├── scripts/
│ ├── generate_icons.py # regenerate the toolbar icons
│ └── build_psl.py # regenerate src/lib/public_suffix_list.js
└── docs/
├── ARCHITECTURE.md
├── DETECTION.md
└── PRIVACY.md
- Edit the source.
- Hit
⟳on the extension inchrome://extensions(or click Reload). - Re-open the target page.
There is no build step. Because inject.js and content.js ship directly,
you can set breakpoints on them from DevTools → Sources → the extension's
origin (content.js) and the page's origin (inject.js, as a page script).
The easiest sanity check is the FingerprintJS demo — it will light up every category of interest.
https://fingerprintjs.github.io/fingerprintjs/
Expected after one page load:
- Badge: red (
severe), ~150–300 hits - Categories: canvas_2d, webgl, audio, fonts, navigator, plugins, screen, match_media, timezone, permissions
- Scripts: one or two
fingerprintjs.github.ioassets
- Add an entry to
CATEGORIESinsrc/lib/detectors.js. - Add hook code to
src/inject.jsthat callsrecord("<category_id>", "<api>"). - Reload the extension — the popup and dashboard pick it up automatically.
None are shipped (the whole codebase is hand-written, dependency-free). If you'd like to add ESLint or TypeScript, PRs welcome.
| Extension | Detect | Block | Spoof | MV3 | Source |
|---|---|---|---|---|---|
| FP Radar (this) | ✅ | — | — | ✅ | this repo |
| CanvasBlocker (Firefox) | — | ✅ | ✅ | — | joue/canvasblocker |
| Chameleon | ✅ | — | ✅ | — | ghostwords/chameleon (Chrome, MV2) |
| DFPM | ✅ | — | — | — | freethenation/DFPM |
| FPMON (academic) | ✅ | — | — | — | fpmon.github.io |
| Fingerprint-Defender | ✅ | ✅ | ✅ | ✅ | dpejoh/fingerprint-defender |
| Chromixer | — | ✅ | ✅ | ✅ | arman-bd/chromixer |
| Privacy Badger | — | ✅ | — | ✅ | EFFTechnologists/privacybadger |
FP Radar is deliberately detection-only. Blocking / spoofing extensions are valuable but have a known cost: they turn your browser into a uniquely-noisy fingerprint of its own. Radar sits alongside them (or on its own) and just tells you what's happening.
- Firefox MV3 port (
scripting.executeScriptwithworld: "MAIN"). - Optional alert mode that interrupts the page with a confirmation dialog the first time a script crosses a risk threshold.
- CSV / HAR-like export for research use.
- Heuristic clustering: group third-party scripts by registrable domain.
- Opt-in blocking of the top-N categories, with the noise-fingerprint trade-off explained up front.
- WebAssembly/CreepJS-style cross-validation that flags tamper-detected extensions running alongside Radar.
MIT © FP Radar contributors.
Uses no third-party code. The detection catalog is informed by the published work of: