This is a code-first walkthrough of how a browser fingerprint is collected in JavaScript: a minimal collector you can paste into your browser console and run. If you have read browser fingerprinting techniques for the concepts, this is the version with working examples. It also covers the architectural choice that matters most in production: where the scoring work happens, on the client or the server.

The code samples below are deliberately small. A production SDK does more cross-checking, more error handling, and more cryptographic hardening of the collected payload. The goal here is to give you a clear mental model of what the wire format actually contains. For the conceptual background (what these signals add up to, and how browser-level identity differs from device-level identity), see what is a browser fingerprint and what is device fingerprinting.

The minimal collector

A working browser-side device fingerprinter can be written in about 120 lines. The structure is:

async function collectFingerprint() {
  const [ua, screen, hw, canvas, webgl, audio, fonts, tz, behavior] = await Promise.all([
    collectUserAgent(),
    collectScreen(),
    collectHardware(),
    collectCanvas(),
    collectWebGL(),
    collectAudio(),
    collectFonts(),
    collectTimezone(),
    collectBehaviorSnapshot(),
  ]);

  return { ua, screen, hw, canvas, webgl, audio, fonts, tz, behavior, collectedAt: Date.now() };
}

That is the wire format. Each value is a small dictionary of low-entropy properties. The server combines them, hashes them where appropriate, and resolves to a stable identifier.

Below are each of the collectors in turn.

1. User-Agent and Client Hints

The legacy navigator.userAgent is still readable but no longer reliable for fine-grained device identification. The Client Hints API is the modern replacement on Chromium, and it returns more useful information when you ask for it explicitly.

async function collectUserAgent() {
  const uaString = navigator.userAgent;
  const uaData = navigator.userAgentData;

  if (!uaData) {
    return { uaString, source: 'legacy' };
  }

  const high = await uaData.getHighEntropyValues([
    'architecture',
    'bitness',
    'model',
    'platformVersion',
    'fullVersionList',
    'wow64',
  ]).catch(() => null);

  return {
    uaString,
    brands: uaData.brands,
    mobile: uaData.mobile,
    platform: uaData.platform,
    high,
    source: 'ua-client-hints',
  };
}

getHighEntropyValues() returns a Promise. The browser gates the high-entropy fields behind a Permissions-Policy header named ch-ua-high-entropy-values, so if you are running this in an embedded context with restrictive policy headers, the call will return null. The MDN reference has the full list of available fields (MDN: getHighEntropyValues).

On Safari and Firefox userAgentData is undefined. The collector falls back to the legacy User-Agent string, which is itself useful when paired with TLS fingerprint cross-checks at the server - you can decode a JA4 to see what the network layer adds.

2. Screen and viewport

Screen properties are cheap and stable. They are also distinctive: the combination of physical resolution, device pixel ratio, available height (which exposes the taskbar height on Windows and the menu bar on macOS) and color depth is enough to bucket a device into a few thousand possibilities by itself.

function collectScreen() {
  return {
    width: window.screen.width,
    height: window.screen.height,
    availWidth: window.screen.availWidth,
    availHeight: window.screen.availHeight,
    colorDepth: window.screen.colorDepth,
    pixelDepth: window.screen.pixelDepth,
    pixelRatio: window.devicePixelRatio,
    orientation: window.screen.orientation?.type ?? null,
    prefersDark: window.matchMedia('(prefers-color-scheme: dark)').matches,
    prefersReducedMotion: window.matchMedia('(prefers-reduced-motion)').matches,
    forcedColors: window.matchMedia('(forced-colors: active)').matches,
  };
}

The prefers-* media queries are useful both as fingerprint components and as accessibility-preserving data points: a device with prefers-reduced-motion enabled is probably not a freshly provisioned bot.

3. Hardware properties

The navigator exposes a handful of quantised hardware properties. None of them are unique. The joint distribution narrows the search significantly.

function collectHardware() {
  return {
    hardwareConcurrency: navigator.hardwareConcurrency ?? null,
    deviceMemory: navigator.deviceMemory ?? null,
    maxTouchPoints: navigator.maxTouchPoints ?? 0,
    platform: navigator.platform,
    languages: navigator.languages,
    language: navigator.language,
    vendor: navigator.vendor,
    cookieEnabled: navigator.cookieEnabled,
    onLine: navigator.onLine,
    pdfViewerEnabled: navigator.pdfViewerEnabled ?? null,
    webdriver: navigator.webdriver === true,
  };
}

Two of these are interesting beyond fingerprinting. navigator.webdriver is true when the browser is driven by WebDriver (Selenium, the WebDriver BiDi protocol, some Puppeteer configurations). Reading it is the first thing every bot detector does. navigator.pdfViewerEnabled is true on desktop Chrome by default and false on a freshly built Puppeteer because the bundled PDF viewer is not present. The two signals together catch a non-trivial fraction of low-effort automation.

4. Canvas fingerprinting

Canvas is the main active probe; the full discussion of why it works is in canvas fingerprinting. The collector is short.

async function collectCanvas() {
  const canvas = document.createElement('canvas');
  canvas.width = 240;
  canvas.height = 60;
  const ctx = canvas.getContext('2d');
  if (!ctx) return { available: false };

  ctx.textBaseline = 'top';
  ctx.font = '14px "Arial"';
  ctx.fillStyle = '#f60';
  ctx.fillRect(125, 1, 62, 20);
  ctx.fillStyle = '#069';
  ctx.fillText('Cwm fjordbank glyphs vext quiz \u{1F600}', 2, 15);
  ctx.fillStyle = 'rgba(102, 204, 0, 0.7)';
  ctx.fillText('Cwm fjordbank glyphs vext quiz \u{1F600}', 4, 17);

  const dataUrl = canvas.toDataURL();
  return {
    available: true,
    hash: await sha256(dataUrl),
    geometryProbe: await canvasGeometry(),
  };
}

Storing the hash is fine for the wire format, but the full data URL should be transmitted at least intermittently so the server can re-hash with different algorithms and re-check the result. A hash alone cannot be re-analyzed if the hashing strategy needs to change.

5. WebGL renderer and parameters

WebGL contributes both metadata (renderer and vendor strings) and active rendering (a 3D scene the GPU has to compute).

function collectWebGL() {
  const canvas = document.createElement('canvas');
  const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
  if (!gl) return { available: false };

  const dbg = gl.getExtension('WEBGL_debug_renderer_info');
  const renderer = dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : null;
  const vendor = dbg ? gl.getParameter(dbg.UNMASKED_VENDOR_WEBGL) : null;

  return {
    available: true,
    renderer,
    vendor,
    version: gl.getParameter(gl.VERSION),
    shadingLanguageVersion: gl.getParameter(gl.SHADING_LANGUAGE_VERSION),
    maxTextureSize: gl.getParameter(gl.MAX_TEXTURE_SIZE),
    maxRenderbufferSize: gl.getParameter(gl.MAX_RENDERBUFFER_SIZE),
    maxVertexAttribs: gl.getParameter(gl.MAX_VERTEX_ATTRIBS),
    aliasedLineWidthRange: Array.from(gl.getParameter(gl.ALIASED_LINE_WIDTH_RANGE)),
  };
}

UNMASKED_RENDERER_WEBGL returns strings like ANGLE (Apple, ANGLE Metal Renderer: Apple M2 Pro, Unspecified Version). The format and content vary across OS, browser and driver. It is masked in Firefox by default and in Safari outside specific contexts, so the parameters that follow act as a fallback fingerprint.

6. AudioContext fingerprinting

The synthesized-audio probe described in the techniques post. The 500-sample slice is enough to differentiate hardware on every major browser other than Safari in Private Browsing mode.

async function collectAudio() {
  const AC = window.OfflineAudioContext || window.webkitOfflineAudioContext;
  if (!AC) return { available: false };

  const ctx = new AC(1, 44100, 44100);
  const osc = ctx.createOscillator();
  osc.type = 'triangle';
  osc.frequency.value = 10000;

  const comp = ctx.createDynamicsCompressor();
  comp.threshold.value = -50;
  comp.knee.value = 40;
  comp.ratio.value = 12;
  comp.attack.value = 0;
  comp.release.value = 0.25;

  osc.connect(comp);
  comp.connect(ctx.destination);
  osc.start(0);

  const buffer = await ctx.startRendering();
  const data = buffer.getChannelData(0);
  let sum = 0;
  for (let i = 4500; i < 5000; i++) sum += Math.abs(data[i]);

  return { available: true, fingerprint: sum.toString() };
}

7. Font enumeration

The standard approach is to render a string in each candidate font and a baseline fallback, then compare the rendered width. Differences mean the font is installed.

function collectFonts() {
  const testString = 'mmmmmmmmmmlli';
  const testSize = '72px';
  const baseFonts = ['monospace', 'sans-serif', 'serif'];
  const candidates = [
    'Arial', 'Verdana', 'Helvetica', 'Times New Roman', 'Courier New',
    'Tahoma', 'Trebuchet MS', 'Impact', 'Comic Sans MS', 'Lucida Console',
    'Segoe UI', 'Segoe UI Variable', 'Calibri',
    'SF Pro', 'Apple Color Emoji', 'Hiragino Kaku Gothic Pro', 'PingFang SC',
    'Noto Sans CJK', 'Liberation Sans', 'DejaVu Sans',
  ];

  const span = document.createElement('span');
  span.style.cssText = `position:absolute;left:-9999px;font-size:${testSize};`;
  span.textContent = testString;
  document.body.appendChild(span);

  const baseline = {};
  for (const base of baseFonts) {
    span.style.fontFamily = base;
    baseline[base] = { w: span.offsetWidth, h: span.offsetHeight };
  }

  const present = [];
  for (const font of candidates) {
    let detected = false;
    for (const base of baseFonts) {
      span.style.fontFamily = `'${font}',${base}`;
      const w = span.offsetWidth;
      const h = span.offsetHeight;
      if (w !== baseline[base].w || h !== baseline[base].h) {
        detected = true;
        break;
      }
    }
    if (detected) present.push(font);
  }

  document.body.removeChild(span);
  return { present };
}

This is the canonical version of the technique. The FontFace.load() API in modern browsers allows a slightly cleaner implementation, and document.fonts.check() can verify font availability without the measurement dance, but the rendering-comparison approach still works everywhere and is what most production libraries use.

8. Time zone and locale

A short collector, often more useful than people expect because of cross-checks against IP-derived geography.

function collectTimezone() {
  const intl = Intl.DateTimeFormat().resolvedOptions();
  return {
    timeZone: intl.timeZone,
    locale: intl.locale,
    calendar: intl.calendar,
    numberingSystem: intl.numberingSystem,
    timeZoneOffset: new Date().getTimezoneOffset(),
  };
}

The Intl.DateTimeFormat().resolvedOptions().timeZone value is what the browser reports as the resolved IANA time zone (America/New_York, Europe/Berlin). A bot that does not bother to spoof this will return UTC or Etc/GMT, which is rare on real consumer devices and a strong signal on its own.

9. Behavioral snapshot

This collector captures the very first frame of behavioral data the page can see, which helps distinguish a fresh-loaded headless browser from one that a human has navigated to.

function collectBehaviorSnapshot() {
  return new Promise((resolve) => {
    let mouseMoves = 0;
    let scrolls = 0;
    let touches = 0;
    let keys = 0;
    let firstInteraction = null;
    const startedAt = performance.now();

    const onMove = () => { mouseMoves++; if (firstInteraction === null) firstInteraction = performance.now(); };
    const onScroll = () => { scrolls++; };
    const onTouch = () => { touches++; };
    const onKey = () => { keys++; };

    window.addEventListener('mousemove', onMove, { passive: true });
    window.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('touchstart', onTouch, { passive: true });
    window.addEventListener('keydown', onKey, { passive: true });

    setTimeout(() => {
      window.removeEventListener('mousemove', onMove);
      window.removeEventListener('scroll', onScroll);
      window.removeEventListener('touchstart', onTouch);
      window.removeEventListener('keydown', onKey);
      resolve({
        windowMs: performance.now() - startedAt,
        mouseMoves,
        scrolls,
        touches,
        keys,
        timeToFirstInteraction: firstInteraction ? firstInteraction - startedAt : null,
      });
    }, 1500);
  });
}

This is a small snapshot. A real behavioral collector samples every move, every scroll velocity, every touch, plus focus and blur transitions, and computes derived statistics server-side. The 1.5-second window is enough to distinguish a page someone is actually using from a page that was just headlessly loaded for the fingerprint payload.

The hashing helper

Used in the canvas collector. The browser exposes SHA-256 natively via SubtleCrypto.

async function sha256(input) {
  const buf = new TextEncoder().encode(input);
  const hash = await crypto.subtle.digest('SHA-256', buf);
  return Array.from(new Uint8Array(hash))
    .map((b) => b.toString(16).padStart(2, '0'))
    .join('');
}

Why the heavy lifting belongs server-side

The collector above is the easy part. Everything that decides what to do with the data should happen on the server, for three reasons.

Replay resistance. Anything the browser computes can be replayed. An attacker can collect a real fingerprint on a real device, then send the recorded JSON from a bot. The server should treat the wire format as untrusted input, cross-check it against signals the browser cannot fake (TLS, IP, header order), and reject sessions where the joint distribution is inconsistent.

Auditability. A risk score generated in the browser is impossible to audit after the fact. A score generated server-side, with the inputs and the decision logic logged, can be reviewed when a customer disputes a block.

Confidentiality. Detection rules are easier to evade if they run in the browser. Sealed server-side scoring keeps the detection logic out of attacker hands. The Foil SDK ships a thin collector and a sealed server pipeline. Open-source fingerprinting libraries that expose the full hashing strategy client-side are useful as references but lose entropy quickly under sustained attack precisely because everything is visible.

This is also why mature device intelligence products converged on the same architecture: client SDK collects, server resolves to a stable identity, and the API returns the resolved identity plus a verdict, not a hash.

Open-source libraries worth knowing about

For evaluation and prototyping rather than production:

  • thumbmarkjs (GitHub). MIT-licensed. Active maintenance and a clean codebase suitable for understanding the per-signal collectors.
  • clientjs (GitHub). Long-running but unmaintained. Useful as a reference for what each signal looks like.
  • creepjs (GitHub). Aimed at illustrating fingerprint spoofing detection rather than identity generation. Good for seeing the cross-checks in action.

If you are building from scratch, start with the collector pattern in this article, add the cross-checks in browser fingerprinting techniques, and put the scoring on the server.

Further reading