Canvas Fingerprinting
How a canvas fingerprint is produced, why it carries so much entropy, and how randomization defenses give themselves away.
Published · Updated
- Category
- Rendering
- API
HTMLCanvasElement.toDataURL- Reveals
- GPU, font rasteriser, platform
- Spoofing
- Spoofable, spoof detectable
- Since
- 2012
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 returnnull. 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
- Mowery & Shacham, Pixel Perfect: Fingerprinting Canvas in HTML5 (2012) — the paper that introduced the technique.
- MDN:
HTMLCanvasElement.toDataURL() - Longer walkthrough with hashing code: Canvas Fingerprinting, End to End
How the probe works
This is the exact source that ran in the panel above — no summary, no drift.
// 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);
},
};
}