← dictionary
Rendering

Canvas Fingerprinting

How a canvas fingerprint is produced, why it carries so much entropy, and how randomization defenses give themselves away.

Category
Rendering
API
HTMLCanvasElement.toDataURL
Reveals
GPU, font rasteriser, platform
Spoofing
Spoofable, spoof detectable
Since
2012
Liverunning the probe…

Canvas fingerprinting asks the browser to draw a fixed scene, then reads the pixels back with toDataURL() or getImageData(). The same drawing commands produce subtly different bytes on different machines: the GPU, the graphics driver, the installed fonts, the anti-aliasing and sub-pixel hinting rules, and the OS emoji set all leave marks. None of that is exposed to the page directly, but all of it bleeds into the rasterized output.

The live panel above ran the probe twice. If the two readings match, your browser is rendering deterministically. If they differ, something is perturbing the output on purpose — keep reading.

Why canvas fingerprinting carries so much entropy

The scene is chosen to maximize divergence rather than to look like anything. Three ingredients do most of the work:

  • Text. Glyph rasterization depends on the font file, the hinting engine, and the sub-pixel anti-aliasing configuration. The same 15px Arial is not the same 15px Arial across a Windows, macOS, and Linux box.
  • A color emoji. Emoji are drawn from a font that ships with the OS — Apple Color Emoji, Noto, Segoe UI Emoji — so a single glyph often separates whole platform families.
  • A composited arc. globalCompositeOperation = "multiply" forces blend math, which diverges across GPU/driver stacks more than flat fills do.

What makes a canvas readout valuable to a tracker is not any particular number of bits — that depends entirely on who else is in the population being compared — but that the value is emergent. It is not a string the site asked for and the browser answered; it falls out of work actually performed, which is why it resists trivial spoofing in a way navigator.platform does not. It is also why a canvas readout that disagrees with the claimed platform is one of the louder automation tells.

Spoofing, and detecting the spoof

Because canvas is a known signal, privacy tools fight back. There are two broad strategies, and each is detectable in its own way.

Per-reading noise (Brave, Firefox with privacy.resistFingerprinting). The browser adds tiny, session- or origin-keyed perturbations to the pixels. This defeats naïve matching, but it also means two consecutive readings of the same scene disagree. The probe above draws twice for exactly this reason — a mismatch is a strong tell that randomization is switched on, which is itself a narrowing signal (it says "this is a privacy-conscious browser").

Uniform output (Tor Browser). Rather than randomize, Tor makes everyone return the same value by rendering through a locked-down, consistent path and prompting before a real readout. A canvas that comes back byte-identical to the known Tor constant is a fingerprint too — of Tor.

The general shape recurs across this whole dictionary — WebGL and audio defenses leave the same kind of mark: a defense that changes a signal to something unusual often trades one identifying value for another. The only defenses that don't are the ones that make you look like a large, common crowd.

Notes for measuring it yourself

  • getContext("2d") can legitimately return null. A refusal to give you a context is a data point; record it rather than treating it as an error.
  • Keep the scene fixed. Any timestamp, random value, or layout-dependent size turns the signal into noise and destroys the measurement.
  • The raw data URL is multiple kilobytes; hash it (SHA-256 is in every browser) and carry the digest, not the blob.

References

How the probe works

This is the exact source that ran in the panel above — no summary, no drift.

JavaScript
// Canvas fingerprint probe. Draws a fixed scene — text with an emoji, a
// gradient, a composited arc — then hashes the PNG readout. The scene is drawn
// twice so per-draw randomization (noise injection) shows up as instability.
import { sha256Hex } from "../fp.js";

function draw() {
  const canvas = document.createElement("canvas");
  canvas.width = 280;
  canvas.height = 80;

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

  ctx.textBaseline = "top";
  ctx.fillStyle = "#f2f2f2";
  ctx.fillRect(0, 0, 280, 80);

  const gradient = ctx.createLinearGradient(0, 0, 280, 80);
  gradient.addColorStop(0, "#ff2d78");
  gradient.addColorStop(0.5, "#8b5cf6");
  gradient.addColorStop(1, "#22d3ee");
  ctx.fillStyle = gradient;
  ctx.fillRect(8, 8, 264, 26);

  // Text exercises font rasterization; the emoji pulls in the color-emoji
  // font, which differs by OS and vendor.
  ctx.fillStyle = "#069";
  ctx.font = '15px "Arial"';
  ctx.fillText("browsernerds.com — canvas probe \u{1F50D}\u{1F98A}", 10, 42);

  // Blend math diverges across implementations more than flat fills do.
  ctx.globalCompositeOperation = "multiply";
  ctx.beginPath();
  ctx.arc(230, 44, 24, 0, Math.PI * 2, true);
  ctx.fillStyle = "rgba(0, 200, 180, 0.7)";
  ctx.fill();

  return canvas;
}

export async function probe() {
  const first = draw();
  if (!first) {
    throw new Error("2D context unavailable — canvas rendering is blocked here");
  }
  const a = first.toDataURL();
  const b = draw().toDataURL();

  const hash = await sha256Hex(a);
  const stable = a === b;

  return {
    hash,
    facts: {
      "Rendering": stable ? "deterministic — no noise injected" : "randomised by a privacy defence",
    },
    label: `${hash.slice(0, 12)}${stable ? "stable" : "noisy — randomization active"}`,
    value: {
      "PNG readout hash (SHA-256)": hash,
      "Data URL length": a.length,
      "Identical across two draws": stable,
    },
    render(el) {
      first.className = "live-canvas";
      el.append(first);
    },
  };
}