How to check browser fingerprinting
Install a probe with Page.addScriptToEvaluateOnNewDocument that wraps canvas, WebGL, audio, font and navigator APIs, then load the page and print the call log. The fixture below made 17 calls across six API families. None of those API names appears anywhere in its source.
Why check this
Fingerprinting is not a request you can spot in the Network panel. It is a sequence of reads inside the page that ends in one string, and the string can leave later, from anywhere, or never leave at all. Reading the script does not help either: the fixture here builds its code from a base64 string, and a text search for toDataURL over its source returns nothing.
Run this on any page that loads a tag you did not write, and before a consent review where the vendor claims it collects device data only for fraud checks. The failure it catches is a script that reads a canvas hash and a WebGL renderer string before the consent banner is answered, which no cookie audit reports.
Prerequisites
- Node 22. Save
fp-site.mjsand start it. It serves three pages: one that fingerprints, one control that draws a chart, and one that dodges the probe. probe.jsandfp.mjsin the same directory.probe.jswraps the APIs;fp.mjsinstalls it before any page script runs.- Chrome 152 driven over the DevTools Protocol. The counts are one capture on one machine on 2026-09-12, and they change with the page, not with the machine.
// fp-site.mjs three pages: / fingerprints, /plain draws a chart, /evade dodges the probe
import { createServer } from 'node:http';
const snippet = `
const c = document.createElement('canvas');
const g = c.getContext('2d');
g.textBaseline = 'top'; g.font = '14px Arial';
g.fillText('h2check 0123', 2, 2);
const canvasHash = c.toDataURL().slice(-32);
const gl = document.createElement('canvas').getContext('webgl');
const dbg = gl.getExtension('WEBGL_debug_renderer_info');
const renderer = gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL);
const ac = new OfflineAudioContext(1, 4096, 44100);
const osc = ac.createOscillator(); osc.connect(ac.destination); osc.start();
const fonts = ['Arial', 'Impact', 'Papyrus'].filter((f) => document.fonts.check('12px "' + f + '"'));
const id = [canvasHash, renderer, navigator.hardwareConcurrency, navigator.platform,
navigator.languages.join(','), screen.width + 'x' + screen.height + 'x' + screen.colorDepth,
Intl.DateTimeFormat().resolvedOptions().timeZone, fonts.join('/')].join('|');
document.title = 'id ' + id.length + ' chars';
`;
const fingerprintPage = `<!doctype html><meta charset="utf-8"><title>Loading</title>
<h1>Fingerprint fixture</h1>
<script>new Function(atob('${Buffer.from(snippet, 'utf8').toString('base64')}'))();</script>`;
const plainPage = `<!doctype html><meta charset="utf-8"><title>Chart</title>
<h1>Chart fixture</h1><canvas id="c" width="200" height="80"></canvas>
<script>
const g = document.getElementById('c').getContext('2d');
for (const [i, v] of [40, 65, 20, 75].entries()) g.fillRect(i * 50 + 10, 80 - v, 30, v);
</script>`;
const evadePage = `<!doctype html><meta charset="utf-8"><title>Evade</title>
<h1>Evade fixture</h1>
<script>
const f = document.createElement('iframe');
f.style.display = 'none';
document.body.append(f);
const clean = f.contentWindow.HTMLCanvasElement.prototype.toDataURL;
const c = document.createElement('canvas');
c.getContext('2d').fillRect(0, 0, 2, 2);
document.title = 'id ' + clean.call(c).length + ' chars';
</script>`;
createServer((req, res) => {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
res.end(req.url.startsWith('/plain') ? plainPage
: req.url.startsWith('/evade') ? evadePage : fingerprintPage);
}).listen(9626, 'localhost', () => console.log('fingerprint fixture on localhost:9626'));
// probe.js wraps the APIs a fingerprint is built from and records every call
globalThis.__fp = [];
const log = (name, arg) => __fp.push(arg === undefined ? name : name + '(' + arg + ')');
const wrapMethod = (obj, name, label) => {
const fn = obj?.[name];
if (typeof fn !== 'function') return;
obj[name] = function (...a) { log(label, a[0]); return fn.apply(this, a); };
};
const wrapGetter = (obj, name, label) => {
const d = Object.getOwnPropertyDescriptor(obj, name);
if (!d?.get) return;
Object.defineProperty(obj, name, { get() { log(label); return d.get.call(this); } });
};
wrapMethod(HTMLCanvasElement.prototype, 'toDataURL', 'canvas.toDataURL');
wrapMethod(HTMLCanvasElement.prototype, 'getContext', 'canvas.getContext');
wrapMethod(CanvasRenderingContext2D.prototype, 'fillText', 'canvas2d.fillText');
wrapMethod(CanvasRenderingContext2D.prototype, 'measureText', 'canvas2d.measureText');
wrapMethod(WebGLRenderingContext.prototype, 'getParameter', 'webgl.getParameter');
wrapMethod(WebGLRenderingContext.prototype, 'getExtension', 'webgl.getExtension');
wrapMethod(FontFaceSet.prototype, 'check', 'fonts.check');
for (const p of ['userAgent', 'platform', 'hardwareConcurrency', 'deviceMemory', 'languages', 'plugins'])
wrapGetter(Navigator.prototype, p, 'navigator.' + p);
for (const p of ['width', 'height', 'colorDepth'])
wrapGetter(Screen.prototype, p, 'screen.' + p);
for (const name of ['AudioContext', 'OfflineAudioContext']) {
const C = globalThis[name];
if (!C) continue;
globalThis[name] = new Proxy(C, { construct(t, a) { log('new ' + name); return Reflect.construct(t, a); } });
}
const ro = Intl.DateTimeFormat.prototype.resolvedOptions;
Intl.DateTimeFormat.prototype.resolvedOptions = function () { log('Intl.DateTimeFormat.resolvedOptions'); return ro.call(this); };
// fp.mjs installs probe.js before any page script, then prints the call log
import { open } from '../../scripts/browser/session.mjs';
import { readFileSync } from 'node:fs';
const probe = readFileSync('probe.js', 'utf8');
const tally = (calls) => {
const counts = calls.reduce((a, c) => ((a[c] = (a[c] || 0) + 1), a), {});
return Object.entries(counts).map(([k, v]) => String(v).padStart(3) + ' ' + k).join('\n');
};
const load = async (url, before, args = []) => {
const s = await open({ args });
try {
if (before) {
await s.cdp.send('Page.enable');
await s.cdp.send('Page.addScriptToEvaluateOnNewDocument', { source: probe });
}
await s.goto(url);
if (!before) await s.page.evaluate(probe);
await new Promise((r) => setTimeout(r, 400));
return { calls: await s.page.evaluate(() => globalThis.__fp), title: await s.page.title(),
version: await s.browser.version() };
} finally { await s.close(); }
};
const a = await load('http://localhost:9626/', true);
console.log('chrome ' + a.version);
console.log('\n--- A. probe installed before the page script ---');
console.log(tally(a.calls));
console.log('total instrumented calls: ' + a.calls.length);
console.log('page title after the script ran: ' + a.title);
const b = await load('http://localhost:9626/', false);
console.log('\n--- B. same probe, injected after the load finished ---');
console.log('total instrumented calls: ' + b.calls.length);
const c = await load('http://localhost:9626/plain', true);
console.log('\n--- C. control page, same probe ---');
console.log(tally(c.calls));
console.log('total instrumented calls: ' + c.calls.length);
const d = await load('http://localhost:9626/evade', true);
console.log('\n--- D. page that takes a clean reference from an iframe ---');
console.log(tally(d.calls));
console.log('total instrumented calls: ' + d.calls.length);
console.log('page title after the script ran: ' + d.title);
console.log('\n--- what the page source says ---');
const src = await (await fetch('http://localhost:9626/')).text();
console.log('bytes: ' + src.length);
for (const needle of ['toDataURL', 'getParameter', 'OfflineAudioContext', 'fonts.check', 'hardwareConcurrency'])
console.log(` "${needle}" in source: ` + src.includes(needle));
Steps
- Step 1.
Search the page source for the API names first, so you know what reading the code would have told you.
node fp.mjs | tail -7--- what the page source says --- bytes: 1431 "toDataURL" in source: false "getParameter" in source: false "OfflineAudioContext" in source: false "fonts.check" in source: false "hardwareConcurrency" in source: falseEvery answer is
falseand the page fingerprints anyway. The snippet is base64 inside anew Functioncall, which is one of several shapes that survive a grep. A source search answers a different question from the one being asked. - Step 2.
Install the probe before the page runs and print what it recorded.
node fp.mjschrome Chrome/152.0.7977.76 --- A. probe installed before the page script --- 1 canvas.getContext(2d) 1 canvas2d.fillText(h2check 0123) 1 canvas.toDataURL 1 canvas.getContext(webgl) 1 webgl.getExtension(WEBGL_debug_renderer_info) 1 webgl.getParameter(37446) 1 new OfflineAudioContext 1 fonts.check(12px "Arial") 1 fonts.check(12px "Impact") 1 fonts.check(12px "Papyrus") 1 navigator.hardwareConcurrency 1 navigator.platform 1 navigator.languages 1 screen.width 1 screen.height 1 screen.colorDepth 1 Intl.DateTimeFormat.resolvedOptions total instrumented calls: 17 page title after the script ran: id 191 charsSeventeen calls, and the arguments carry as much as the names.
canvas2d.fillTextdraws text andcanvas.toDataURLreads the pixels back, which is the canvas hash.webgl.getParameter(37446)isUNMASKED_RENDERER_WEBGL, the GPU model.fonts.checkruns once per font name being probed.navigator.userAgentandnavigator.deviceMemoryare wrapped too and do not appear, because this page never read them. - Step 3.
Inject the same probe after the load instead, which is what a Console paste does.
node fp.mjs | grep -A1 "B\."--- B. same probe, injected after the load finished --- total instrumented calls: 0Zero, on the page that made 17 calls a moment ago. Fingerprinting runs during the load. A probe installed after it reports a clean page, and reports it with the same confidence as a correct run.
- Step 4.
Run the control page, so the count means something.
node fp.mjs | grep -A3 "C\."--- C. control page, same probe --- 1 canvas.getContext(2d) total instrumented calls: 1A chart is a canvas too. It asks for a 2d context and never reads the pixels back, so
toDataURLis absent. The finding is the shape of the sequence, not the presence of canvas. - Step 5.
Run the page that takes its function reference from a fresh iframe.
node fp.mjs | grep -A4 "D\."--- D. page that takes a clean reference from an iframe --- 1 canvas.getContext(2d) total instrumented calls: 1 page title after the script ran: id 2146 charsThe probe recorded one call and the page produced a 2146-character data URL, so
toDataURLran. A same-origin iframe carries its own unpatched prototypes, and four lines of script are enough to reach them. Report what the probe found as a floor, never as a total.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| fillText then toDataURL on one canvas | Canvas fingerprinting | Name the script that owns the call and check it against consent. |
| getExtension('WEBGL_debug_renderer_info') | The GPU model is being read | The value identifies a hardware class, not a browser setting. |
| fonts.check repeated with different names | Font enumeration | Count the names. A list of 30 is a probe, one is a layout decision. |
| new OfflineAudioContext with no playback | Audio fingerprinting | An audio context on a page with no sound is the whole finding. |
| A few navigator reads and nothing else | Feature detection | Not a finding on its own. Look for the reads that leave a hash. |
| Zero calls | Either a clean page or a probe installed too late | Confirm the probe ran first, then re-check on the control page. |
Common mistakes
What to check next
- How to check which third parties receive data: where the identifier goes once it has been built.
- How to check which third party scripts a page loads: attributes the calls to a script and an owner.
- How to check if local storage is used before consent: the other half of recognising a returning visitor.
- How to check for tracking pixels: the smallest carrier for a hash on its way out.
- How to check if third party cookies are blocked: fingerprinting is what vendors reach for when cookies stop working.
FAQ
How do I check my browser fingerprint?
An online fingerprint page measures the browser you visit it with, which answers a personal question. This procedure answers a testing one: which APIs a given page called, in order, with what arguments. Run it against your own page, not against a demo site.
Is a high call count a defect?
No. Seventeen calls here describe one identifier; a chart library can call getContext fifty times and describe nobody. Read the sequence. Drawing then reading back is the signature, and a count with no sequence behind it proves nothing.
Can a page tell that the probe is there?
Yes. A wrapped function has a different toString output, and the iframe route in step 5 sidesteps the wrapper without checking for it. The probe is a measurement tool for your own release, not an adversarial one.
Does blocking these APIs fix the problem?
It changes the value, not the collection. A page that reads the same blocked values as every other visitor becomes less identifiable, which is what a consistent browser configuration buys. The check stays the same either way.
Which APIs should the probe wrap?
Start with the families in probe.js: canvas, WebGL, audio, fonts, navigator and screen. Add anything a vendor's own documentation says it reads. A probe is a list you maintain, and the output names exactly which entries were touched.
Verified
Verified by Maks Vernynode 22.23.2Chrome 152.0.7977.76
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
advanced18 minpublished updated Maks Verny