← index

Canvas Fingerprinting, End to End

fingerprintingjavascriptweb-crypto

Demo post. This is a placeholder written to exercise the publishing pipeline. The code below is illustrative — it is not a measurement, a benchmark, or a research finding.

Canvas fingerprinting is one of the oldest and most frequently cited browser signals. The premise is simple: ask the browser to rasterize text and shapes, then read the pixels back out. Differences in GPU, driver, font stack, and anti-aliasing settings mean two machines rarely produce byte-identical output.

Drawing something deterministic

The important word is deterministic. The drawing routine must be identical every time, or the signal becomes noise. Fixed dimensions, fixed colors, fixed text, no timestamps.

JavaScript
function drawProbe() {
  const canvas = document.createElement("canvas");
  canvas.width = 300;
  canvas.height = 60;

  const ctx = canvas.getContext("2d");
  if (!ctx) return null;

  ctx.textBaseline = "top";
  ctx.font = '14px "Arial"';
  ctx.fillStyle = "#f60";
  ctx.fillRect(0, 0, 120, 24);

  ctx.fillStyle = "#069";
  ctx.fillText("Canvas probe — 🔒", 4, 28);

  ctx.globalCompositeOperation = "multiply";
  ctx.beginPath();
  ctx.arc(180, 30, 22, 0, Math.PI * 2, true);
  ctx.fillStyle = "rgba(0, 200, 180, 0.7)";
  ctx.fill();

  return canvas.toDataURL();
}

A couple of notes on the snippet above. getContext("2d") can legitimately return null — some privacy configurations disable the context entirely, and that refusal is itself worth recording. Compositing operations such as multiply are included because blend math tends to differ more across implementations than flat fills do.

Hashing the output

The data URL is long and unwieldy. What you actually want to carry around is a short, stable digest. crypto.subtle gives you SHA-256 without a dependency, though note that it is only available in secure contexts.

TypeScript
async function sha256Hex(input: string): Promise<string> {
  const bytes = new TextEncoder().encode(input);
  const digest = await crypto.subtle.digest("SHA-256", bytes);

  return Array.from(new Uint8Array(digest))
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");
}

export async function canvasFingerprint(): Promise<string> {
  const dataUrl = drawProbe();
  if (dataUrl === null) return "unavailable";
  return sha256Hex(dataUrl);
}

Why a cryptographic hash?

There is nothing secret here, so collision resistance is not a security requirement — it is a bookkeeping one. A short, fixed-width value is easier to store, index, and compare than a multi-kilobyte base64 blob, and SHA-256 is already in every browser.

Things worth keeping in mind

Consideration Why it matters
Determinism Any variable input turns the signal into noise
Null contexts A refusal to render is itself a data point
Randomization Some browsers perturb pixels per-origin or per-session
Consent Collecting device signals has legal and ethical weight

Several browsers now add deliberate per-origin noise to toDataURL, which means a single reading can no longer be assumed stable across sessions. Treat any canvas value as one weak input among many rather than an identifier on its own.