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

// 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

  1. 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: false

    Every answer is false and the page fingerprints anyway. The snippet is base64 inside a new Function call, which is one of several shapes that survive a grep. A source search answers a different question from the one being asked.

  2. Step 2.

    Install the probe before the page runs and print what it recorded.

    node fp.mjs
    
    chrome 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 chars

    Seventeen calls, and the arguments carry as much as the names. canvas2d.fillText draws text and canvas.toDataURL reads the pixels back, which is the canvas hash. webgl.getParameter(37446) is UNMASKED_RENDERER_WEBGL, the GPU model. fonts.check runs once per font name being probed. navigator.userAgent and navigator.deviceMemory are wrapped too and do not appear, because this page never read them.

  3. 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: 0

    Zero, 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.

  4. 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: 1
    

    A chart is a canvas too. It asks for a 2d context and never reads the pixels back, so toDataURL is absent. The finding is the shape of the sequence, not the presence of canvas.

  5. 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 chars

    The probe recorded one call and the page produced a 2146-character data URL, so toDataURL ran. 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

Sign: The probe is pasted into the Console and reports zero calls on a page that fingerprints.Cause: Page.addScriptToEvaluateOnNewDocument runs before the first page script; a Console paste runs after the last one. The same probe on the same fixture recorded 17 calls installed early and 0 installed late, in two runs of one script.
Sign: A source search for canvas and WebGL API names finds nothing, and the page is signed off.Cause: The fixture stores its code as base64 and calls new Function on it. All five API names return false from a text search of the 1431-byte page, and the script still builds a 191-character identifier. Only the runtime knows what was called.
Sign: A page reads pixels back from a canvas and the probe never logs it.Cause: Prototype patching covers the main window. A same-origin iframe has its own copies, so f.contentWindow.HTMLCanvasElement.prototype.toDataURL is unwrapped. The evade page produced a 2146-character data URL while the probe counted one call. Treat the list as evidence of what happened, not proof of what did not.
Sign: Any use of canvas is reported as fingerprinting.Cause: The control page asks for a 2d context and draws bars, one instrumented call and no read-back. Getting a context is drawing. Reading the pixels back and hashing them is identification.

What to check next

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.

advanced18 minpublished updated Maks Verny