Browser fingerprinting in 2KB: how Broprint.js works

May 14, 2024

|repo-review

by Florian Narr

Browser fingerprinting in 2KB: how Broprint.js works

Browser fingerprinting is one of those topics where most people reach for FingerprintJS — a mature library with thousands of signals and a paid cloud tier. Broprint.js takes the opposite approach: two signals, no dependencies, 2.12 KB.

What it does

getCurrentBrowserFingerPrint() returns a Promise<string> — a 53-bit hash that combines a canvas rendering fingerprint with an audio processing fingerprint. Different hardware, GPU drivers, and browser engines produce measurably different values, so the result is stable per device but differs across devices.

Why I starred it

The educational disclaimer in the README is honest: this is not production fraud detection. But as a standalone demonstration of how browser fingerprinting actually works at the code level — not the theory, but the actual DOM calls — it's unusually readable. Most fingerprinting libraries are several thousand lines of entropy collection and UA parsing. Here you can trace the whole thing in four files.

The size claim holds up. The published bundle is 2.12 KB because tsup minifies and there are zero runtime dependencies — crypto-js was dropped in v2.0.

How it works

Open src/index.ts and trace the execution. getCurrentBrowserFingerPrint() fires two concurrent operations:

  1. generateTheAudioFingerPrint.run() — async, uses OfflineAudioContext
  2. getCanvasFingerprint() — sync, returns a data URI

The audio path, in src/code/generateTheAudioPrints.ts, uses OfflineAudioContext to render audio that never plays:

function setContext() {
    var audioContext = window.OfflineAudioContext || window.webkitOfflineAudioContext;
    context = new audioContext(1, 44100, 44100);
}

function setOscillator() {
    oscillator = context.createOscillator();
    oscillator.type = "triangle";
    oscillator.frequency.setValueAtTime(10000, currentTime);
}

A triangle wave at 10kHz is fed through a DynamicsCompressor and rendered offline. Then generateFingerprints() sums 500 samples from the rendered buffer (indices 4500–5000) and converts that float accumulation to a string. Different audio stacks — Chrome on macOS versus Firefox on Linux — produce slightly different floating-point results from the same DSP graph, which is the whole point.

The canvas path in src/code/GenerateCanvasFingerprint.ts is simpler: draw a known string ('BroPrint.65@345876') with specific colors and font metrics onto an offscreen canvas, then call .toDataURL(). The pixel encoding differs by GPU and rendering engine.

Both outputs land in src/index.ts where they're concatenated, base64-encoded, and fed into cyrb53 from src/code/EncryptDecrypt.ts:

fingerprint = window.btoa(audioChannelResult as string) + getCanvasFingerprint()
resolve(cyrb53(fingerprint, 0) as unknown as string);

cyrb53 is a well-known non-cryptographic 53-bit hash (safe for JS's floating-point integers). The implementation is clean:

export const cyrb53 = function (str, seed = 0) {
    let h1 = 0xdeadbeef ^ seed, h2 = 0x41c6ce57 ^ seed;
    for (let i = 0, ch; i < str.length; i++) {
        ch = str.charCodeAt(i);
        h1 = Math.imul(h1 ^ ch, 2654435761);
        h2 = Math.imul(h2 ^ ch, 1597334677);
    }
    h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
    h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
    return 4294967296 * (2097151 & h2) + (h1 >>> 0);
};

The fallback path in src/index.ts is also worth noting — if the audio context throws, the library degrades to canvas-only rather than rejecting the promise entirely. That's a sensible default for environments where OfflineAudioContext is blocked.

There's also a src/webRTC.js file in the repo that doesn't surface in the public API — it collects local IP addresses via WebRTC, which could have been a third signal. It's there, unused.

Using it

npm i @rajesh896/broprint.js
import { getCurrentBrowserFingerPrint } from "@rajesh896/broprint.js";

getCurrentBrowserFingerPrint().then((fingerprint) => {
    console.log(fingerprint); // e.g. 6533356943844037
});

v2.2.0 added proper multi-format builds via tsup — ESM, CJS, and an IIFE global. CDN usage now works without a bundler:

<script type="module">
    import { getCurrentBrowserFingerPrint } from 'https://cdn.jsdelivr.net/npm/@rajesh896/broprint.js@latest/lib/index.mjs';
    getCurrentBrowserFingerPrint().then(fp => console.log(fp));
</script>

Rough edges

The test script in package.json is echo "Error: no test specified" — there's no test suite at all. For something built around floating-point arithmetic and browser API behavior, that's a real gap. You can't run this server-side or in Node.js (no window, no OfflineAudioContext), and there's nothing in the code to detect that and fail gracefully before throwing.

The Brave browser branch in src/index.ts is worth reading:

if ((navigator.brave && await navigator.brave.isBrave() || false))
    fingerprint = window.btoa(audioChannelResult as string) + getCanvasFingerprint()
else
    fingerprint = window.btoa(audioChannelResult as string) + getCanvasFingerprint()

Both branches do exactly the same thing. The @todo comment acknowledges it: // @todo - make fingerprint unique in brave browser. Brave blocks or randomizes both canvas and audio APIs, so the output is likely less stable there. The code detects Brave but doesn't yet do anything different.

The src/webRTC.js file exists but is not imported or exported — dead code. Either it was dropped on purpose or forgotten.

Bottom line

If you need production-grade fingerprinting, reach for FingerprintJS. If you want to understand exactly how canvas and audio fingerprinting work at the API level — the specific DSP parameters, the canvas draw calls, the hashing — Broprint.js is the most readable implementation I've seen. It's also genuinely useful for low-stakes visitor deduplication where cookie-free tracking matters and precision doesn't.

Rajesh-Royal/Broprint.js on GitHub
Rajesh-Royal/Broprint.js