liarjs.dev
← Field notes
Canvas

Canvas fingerprinting and the deterministic-noise rule

July 22, 2026 · 9 min read

Most people building canvas protection obsess over the wrong variable. They tune the amplitude of the noise, argue about whether ±1 or ±2 on the blue channel is more “natural,” and ship. A scanner catches them anyway. The reason is rarely that the noise was too big or too small. It is that the noise changed between two reads of the same draw. Determinism is the whole game. A canvas perturbation that is not a pure function of (seed, input) is not protection. It is a signature announcing “I am tampering with this API.”

The canvas double-read lie test: deterministic noise passes, per-call randomness is judged a lie

The pipeline you are actually defending

Canvas fingerprinting has one shape, repeated across every implementation:

  1. canvas.getContext('2d')
  2. Draw something deliberately awkward: an emoji, text in several fallback fonts, a gradient, a couple of arc() calls. This forces the browser through font rasterization, anti-aliasing and sub-pixel compositing, all of which vary by GPU, driver, OS and font stack.
  3. Read the result back with toDataURL(), toBlob() or ctx.getImageData().
  4. Hash the bytes.

The hash is the fingerprint. WebGL has its own variant (readPixels after a shader pass) and everything above works inside an OffscreenCanvas in a Worker too, which is why worker-context coverage matters. But the 2D path is the one everyone tests first, and BrowserLeaks’ canvas page is the canonical reference for what a “clean” readback looks like.

This is where implementations trip. CreepJS calls getImageData() roughly 130 times in a single run, and it is not building 130 fingerprints. It builds a handful and uses the rest to check whether you tell the same story every time.

The lie test

The core detection is embarrassingly simple. Draw the same thing twice. Read it twice. Compare.

A genuine browser is deterministic. Identical input produces byte-identical output on every call, forever, because rasterization is a pure function of the same inputs on the same machine. So if two reads of the same draw differ, the browser is lying. Something is injecting fresh entropy per call.

Our own scanner runs exactly this check:

// 7. Canvas double-read (per-call noise = lie)
if (client.canvas) {
  if (client.canvas.hash !== client.canvas.hash2)
    add('canvas-lie', 'Canvas read stability', 'bad',
      'Identical canvas draws produced different pixels across two reads (per-call randomization).', 18);
  else
    add('canvas-lie', 'Canvas read stability', 'ok', 'Canvas is stable across repeated identical reads.', 0);
}

Two hashes of the same draw. hash !== hash2 and you are flagged. The detector needs no cleverness here. The entire cost of catching per-call randomization is one extra read.

So the amplitude debate is a distraction. It does not matter how small your noise is. If it is fresh on each call, hash !== hash2 fires on the first double-read. A perfectly invisible ±1 that is re-rolled per call is caught just as fast as a garish ±40 that is re-rolled per call.

Evidence: the source-level fork that lost to engine spoofers

We ran four anti-detect browsers through one CreepJS session, three of them behind the same US residential proxy (same California exit, America/Los_Angeles), so the network variable is eliminated and every difference is the browser engine.

Browser Kernel approach CreepJS lies
bablo Closed BAS engine, engine-level spoofing 0
AdsPower Modified Chromium 0
VMLogin Commercial fingerprint browser 0
CloakBrowser Source-modified Chromium 146, --fingerprint=<seed> flags 3: Canvas / DOMRect / Audio

CloakBrowser is the most technically ambitious of the four. It is a real source-level Chromium fork with the newest engine (UA Chrome 146 == actual kernel 146, zero version gap), a modern RTX 3070 Ti GPU profile and clean WebRTC. On paper it is the closest thing to “doing it right.” It lost on all three noised surfaces for one reason: it randomizes per call.

The cleanest illustration is DOMRect, because the numbers are legible. Reading the same rectangle twice:

CloakBrowser:  A = 0.246032417…   B = 0.246032432…   → A ≠ B  (LIE)
bablo:         A = 0.246032403…   B = 0.246032403…   → A == B (fine)

CloakBrowser re-rolls the noise on every getClientRects() call, so two reads of one rectangle disagree in the last few significant digits. bablo computes the perturbation once from the profile seed and returns the same value forever. The same root cause hits its canvas and its audio. CreepJS tags all three with the lies hash class.

The lesson from controlled anti-detect browser testing is blunt: a source-level Chromium fork lost to engine-level spoofers purely on per-call randomness. Advanced approach ≠ correct implementation.

The takeaway for anyone writing perturbation code:

Noise must be a pure function of (seed, input) → output. Same profile (seed) plus same read operation returns the exact same value on every call; only across profiles does it differ. Never draw a fresh random number on each toDataURL / getClientRects / audio render.

In code terms, the anti-pattern and the fix:

// WRONG - caught on the first double-read
function perturbChannel(v) {
  return v + (Math.random() < 0.5 ? -1 : 1);   // fresh entropy per call
}

// RIGHT - deterministic; identical (seed, x, y, channel) → identical output
function perturbChannel(seed, x, y, channel, v) {
  const h = hash32(seed, x, y, channel);        // pure hash, no global state
  return v + (h & 1 ? 1 : -1);                   // stable across every read
}

The rule generalizes. The same determinism law governs audio (OfflineAudioContext render) and DOMRect (getClientRects), which we will cover in their own posts. Seed in, stable value out.

Determinism is necessary, not sufficient

Passing the double-read is table stakes. Our own first kernel build passed A == B everywhere and still got flagged, because CreepJS has structural checks that a stable-but-wrong value cannot satisfy. Two of them matter most for canvas.

The write-read invariant: keep noise off the getImageData path

CreepJS’s getPixelMods() writes a known opaque pixel with fillRect, then reads it back with getImageData(x, y, 1, 1) and requires the exact bytes it wrote. On a 1×1 read there is nothing to hide behind. It compares your fillStyle input against the getImageData output directly. Any per-pixel noise on the read path shows up immediately as pixel data modified.

The fix is architectural. Move canvas noise off the getImageData path entirely and onto the encode path only, meaning toDataURL / toBlob. Then getImageData returns true pixels (and getPixelMods passes), while the encoded output is still perturbed.

Why does that fool anything? Because mainstream canvas fingerprinters hash the data URI string. They do not decode it back into a pixel buffer to cross-check against getImageData. The encode-path perturbation still changes their hash while the read path stays honest.

The trade-off is real: pure getImageData-based fingerprints are not perturbed under this scheme. That is the price of zero lies. You give up perturbing one (minority) fingerprinting path to keep the write-read invariant intact.

The blank invariant: never noise a transparent pixel

CreepJS clears an 8×8 region with clearRect, reads it back and requires it to be all zero. Math.max(...data) > 0 triggers pixel data modified. If your noise function blindly perturbs every pixel, it nudges a transparent region off zero and gets caught.

The fix: skip blank pixels. The perturbation must skip any pixel with alpha == 0. Blank stays blank; only rendered content is touched.

for (let i = 0; i < data.length; i += 4) {
  if (data[i + 3] === 0) continue;              // alpha==0 → leave blank pixels alone
  data[i]     = perturbChannel(seed, i, 0, data[i]);
  data[i + 1] = perturbChannel(seed, i, 1, data[i + 1]);
  data[i + 2] = perturbChannel(seed, i, 2, data[i + 2]);
  // alpha untouched
}

The same shape of rule applies to audio (skip == 0.0 silence samples) and DOMRect (skip 0×0 ghost rects). If the detector expects a canonical blank/zero value, your noise must preserve it.

The Pixelscan tension: you cannot win both games at once

Here is the honest part that most vendors skip. Pixelscan does not run a lie test in the CreepJS sense. It flags “Canvas: Masking detected” for the mere fact that the readback differs from a clean, un-tampered render. Any ±1 noise, any toDataURL ≠ getImageData divergence, trips it. It is detecting the presence of a mask, not an inconsistency in it.

That produces a genuine dilemma:

Configuration CreepJS / Iphey Pixelscan masking Cross-profile canvas hash
Deterministic anti-correlation noise ON Passes (0 lies) Flagged (masking detected) Unique per profile
Noise OFF (true pixels) Passes Passes Identical across all profiles on the machine

Turn deterministic noise on and every profile on the machine gets a unique canvas hash, which is good for anti-correlation, but Pixelscan says “masking detected.” Turn it off and Pixelscan is happy, yet every profile on that machine now shares one canvas hash and becomes trivially linkable.

No setting satisfies both. This is the fundamental cross-profile uniqueness vs. pass-Pixelscan trade-off, and anyone who tells you their canvas protection “passes everything” either has not tested against Pixelscan or is not making profiles unique. Pick your threat model. If you are running many profiles that must not be linked, take the masking flag. If a single profile needs to look pristine to Pixelscan specifically, run true pixels and accept the linkability.

Checklist

If you write canvas perturbation, verify all of these:

  • Two reads of one draw are byte-identical (hash == hash2). Deterministic (seed, input) → output, no per-call Math.random().
  • getImageData returns true pixels. Noise lives on the encode path (toDataURL/toBlob) only, so getPixelMods()’s 1×1 write-read comparison passes.
  • Blank stays blank: skip alpha == 0 pixels so clearRect regions read all-zero.
  • Coverage extends to the OffscreenCanvas / Worker context, driven by the same seed.
  • You have consciously chosen a side of the Pixelscan masking trade-off and documented it.

Takeaway

What gets you caught is the variance in the noise, never the noise on its own. Make perturbation a pure function of the profile seed and the pixel input so identical reads come back byte-identical, keep it off the getImageData path and off blank pixels, and accept that “unique per profile” and “passes Pixelscan masking” are mutually exclusive. Determinism first. Amplitude is a rounding error by comparison.

Sources & further reading
  1. BrowserLeaks, Canvas
  2. CreepJS (Abraham Juliot)
  3. Pixelscan
More field notes
← Back to field notes