DEV Community

Spicy
Spicy

Posted on

Browser Fingerprinting in Practice — The Signals, the Math, and What Actually Defeats It

Most privacy advice still centers on cookies — clear them, block them, use incognito. Meanwhile, fingerprinting has become the dominant tracking method precisely because it doesn't touch cookies at all. Here's what's actually happening at the API level, and what countermeasures hold up.

The Core Signals (And the Code Behind Them)

Canvas fingerprinting exploits subtle rendering differences between GPU/driver combinations:

function getCanvasFingerprint() {
  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d');

  // Draw text with specific font, size, and emoji — 
  // rendering varies by OS font rasterizer and GPU
  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 🎮', 2, 15);
  ctx.fillStyle = 'rgba(102, 204, 0, 0.7)';
  ctx.fillText('Cwm fjordbank glyphs vext quiz 🎮', 4, 17);

  // Hash the resulting pixel data
  return canvas.toDataURL();
}

// Hash the output for compact comparison
async function hashFingerprint(dataUrl) {
  const encoder = new TextEncoder();
  const data = encoder.encode(dataUrl);
  const hashBuffer = await crypto.subtle.digest('SHA-256', data);
  return Array.from(new Uint8Array(hashBuffer))
    .map(b => b.toString(16).padStart(2, '0')).join('');
}
Enter fullscreen mode Exit fullscreen mode

The same code produces different pixel-level output across GPU vendors (NVIDIA vs AMD vs Apple Silicon), driver versions, and even font hinting settings — none of which the user can see, but all of which produce a consistent hash per device.

WebGL fingerprinting goes deeper into hardware identification:

function getWebGLFingerprint() {
  const canvas = document.createElement('canvas');
  const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
  if (!gl) return null;

  const debugInfo = gl.getExtension('WEBGL_debug_renderer_info');
  return {
    vendor: gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL),
    renderer: gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL),
    // e.g. "ANGLE (NVIDIA, NVIDIA GeForce RTX 4070 Direct3D11..."
    extensions: gl.getSupportedExtensions(),
    maxTextureSize: gl.getParameter(gl.MAX_TEXTURE_SIZE),
  };
}
Enter fullscreen mode Exit fullscreen mode

This often reveals your exact GPU model, which on its own significantly narrows the population of matching devices.

AudioContext fingerprinting uses hardware-dependent audio processing variance:

async function getAudioFingerprint() {
  const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
  const oscillator = audioCtx.createOscillator();
  const analyser = audioCtx.createAnalyser();
  const gainNode = audioCtx.createGain();

  gainNode.gain.value = 0; // silent — user hears nothing
  oscillator.type = 'triangle';
  oscillator.connect(analyser);
  analyser.connect(gainNode);
  gainNode.connect(audioCtx.destination);
  oscillator.start(0);

  const buffer = new Float32Array(analyser.frequencyBinCount);
  analyser.getFloatFrequencyData(buffer);
  oscillator.stop();

  return buffer.slice(0, 30).join(','); // sample of frequency data
}
Enter fullscreen mode Exit fullscreen mode

Font enumeration via measurement comparison (no direct font list API exists, so it's inferred):

function detectFonts(testFonts) {
  const baseFonts = ['monospace', 'sans-serif', 'serif'];
  const testString = 'mmmmmmmmmmlli';
  const testSize = '72px';
  const span = document.createElement('span');
  span.style.fontSize = testSize;
  span.innerHTML = testString;
  document.body.appendChild(span);

  const baseWidths = {};
  baseFonts.forEach(font => {
    span.style.fontFamily = font;
    baseWidths[font] = span.offsetWidth;
  });

  const detected = testFonts.filter(font => {
    return baseFonts.some(base => {
      span.style.fontFamily = `'${font}', ${base}`;
      return span.offsetWidth !== baseWidths[base];
    });
  });

  document.body.removeChild(span);
  return detected;
}
Enter fullscreen mode Exit fullscreen mode

Composite Scoring — How These Combine

A single signal rarely identifies anyone uniquely. The entropy comes from combining them:

import math

def calculate_entropy(signal_distribution: dict) -> float:
    """
    Shannon entropy in bits — higher = more identifying
    e.g. if 1 in 1000 users share your exact value, 
    that signal contributes ~10 bits of entropy
    """
    total = sum(signal_distribution.values())
    entropy = 0
    for count in signal_distribution.values():
        p = count / total
        entropy -= p * math.log2(p)
    return entropy

# Example combined fingerprint entropy
signals = {
    'screen_resolution': 4.2,      # bits
    'canvas_hash': 8.1,
    'webgl_renderer': 6.7,
    'font_list': 5.9,
    'timezone': 2.1,
    'audio_fingerprint': 5.3,
}

total_entropy = sum(signals.values())  # ~32.3 bits
# 2^32.3 ≈ 5.3 billion possible combinations
# Global population ~8 billion — this fingerprint 
# alone approaches unique identification
Enter fullscreen mode Exit fullscreen mode

This is why the EFF Panopticlick research consistently found most tested browsers had fingerprints unique among hundreds of thousands of samples — the combinatorial entropy adds up fast even when no individual signal is rare.


What Actually Reduces Entropy (Tested)

Firefox privacy.resistFingerprinting standardizes the highest-entropy signals:
Testing before/after on the same machine with amiunique.org:

Signal Default Firefox resistFingerprinting
Canvas Unique hash Blocked/randomized
Timezone Local (e.g. PST) UTC
Screen resolution Exact (e.g. 1512x982) Rounded (1500x950)
Fonts detected 40+ system fonts ~12 bundled fonts
WebGL renderer Full GPU string Generic string

The tradeoff: sites relying on accurate viewport dimensions for layout can render incorrectly, and some canvas-dependent web apps (image editors, games) break entirely.

Brave's fingerprinting protection takes a different approach — randomizing per-session rather than blocking:
Tor Browser standardizes nearly everything to a single shared profile across all users, which is the only approach that achieves near-zero fingerprint uniqueness — at the cost of significant performance and compatibility overhead.


Detection-Side: If You're Building Anti-Fraud Systems

For legitimate use cases (fraud detection, not tracking users for ads), fingerprinting libraries like FingerprintJS provide production-ready implementations:

import FingerprintJS from '@fingerprintjs/fingerprintjs';

const fpPromise = FingerprintJS.load();

(async () => {
  const fp = await fpPromise;
  const result = await fp.get();
  console.log(result.visitorId); // stable identifier
  console.log(result.confidence.score); // 0-1 reliability
})();
Enter fullscreen mode Exit fullscreen mode

Worth noting: privacy-focused browsers and extensions specifically target known fingerprinting libraries, so production fraud detection systems increasingly see degraded signal quality from privacy-conscious users — which itself becomes a (weaker) signal.


Practical Takeaway

Cookie-based privacy controls (clearing cookies, incognito mode, cookie blockers) have zero effect on any of the above. If you're building privacy-respecting infrastructure or just hardening your own setup, the signals that matter are canvas, WebGL, audio context, and font enumeration — and the only consistently effective countermeasures are resistFingerprinting-style signal normalization or full standardization (Tor).

Consumer-level explanation without the code: lucas8.com/incognito-mode-browser-fingerprinting

Top comments (0)