There is a seductive, wrong way to mask a canvas: add a little random noise every time the page reads it. It feels safe — the hash is different for everyone! — and it is the single most common way antidetect browsers get flagged. This post is about why, and about the four checks a serious detector runs that "determinism" alone won't survive.
The lie test: repeat a call, expect the same answer
CreepJS — the reference open-source fingerprinting suite — has a category it literally labels lies. One of the simplest lie tests is devastating: call the same operation twice and check the two results are identical. Real hardware is deterministic. A masked browser that rolls fresh noise per call is not.
In controlled head-to-head testing (same residential proxy, same detector, difference is purely the engine):
| Approach | CreepJS lies detected |
|---|---|
| Closed-source engine masking (BAS-style) | 0 |
| Commercial antidetect browser | 0 |
| Patched Chromium with per-call random noise | 3 — Canvas / DOMRect / Audio |
The patched-Chromium build above is on the same architectural tier as Maskole — source-level Chromium, driven by command-line flags. It still got caught, because a rectangle read twice returned0.246032417… and then 0.246032432…. Two different numbers for the same call. Caught. A correct build returns A == B every time.
The rule this forces: noise must be a pure function of (seed, input) → output. Same profile, same read operation, any number of calls — identical output. Only across profiles does it change. Never draw a fresh random number ontoDataURL,getClientRects, or an audio render.
Determinism is necessary — but not sufficient
Here is the part most write-ups miss. Our own first build was fully deterministic — every value passedA == B — and CreepJS still flagged four dimensions. Reverse-engineering the detector's source turned up four classes of check that determinism can't touch. These are the real red lines when you perturb canvas, audio, DOMRect or screen.
1. Blank invariants — don't add noise to something that should be empty
Detectors render deliberately blank data and demand exact zeros. An oscillator atfrequency = 0 must produce pure silence; a clearRect region must read back all-zero; a0×0 "ghost" element must report zero dimensions. Perturb those and you've announced yourself. The fix: the noise function skips blanks — skip == 0 audio samples, skip alpha == 0 pixels, skip empty rects. Blank stays blank; everything else still shifts.
2. Write-read consistency — getImageData must return what you wrote
Write a known opaque pixel with fillRect, read it back with getImageData, and it must match byte-for-byte. At 1×1 you can't hide per-pixel noise there. So canvas noise is removed from the read path entirely and kept only on the encode path (toDataURL / toBlob). Mainstream canvas fingerprints are still masked; the pixel-buffer read stays honest.
3. Cross-path consistency — change a signal, change all its side doors
Override Screen.width in JavaScript but forget the CSS media query, andmatchMedia('(device-width: 1536px)') — which reads a different internal path — disagrees with the JS getter. Instant tell. The fix is to sync at the source: the same persisted value feeds both the Screen API and the media-query engine, both main thread and workers.
4. The dpr-gated hardcoded hash — leave DOMRect alone at dpr = 1
At devicePixelRatio === 1, CreepJS hashes the bounding box of a rotated element and compares it to a hardcoded constant. Any sub-pixel perturbation breaks it, and you can't skip just that one box. Atdpr ≠ 1 the whole check is skipped. So DOMRect noise is auto-gated off unless dpr is genuinely non-1 — and the recommended persona is a real laptop profile (1536×864 @ dpr 1.25, the single most common Windows fingerprint on the web) where the check is skipped and everything else passes under coherent deterministic noise.
Lies and "trash" — two different scoreboards
CreepJS keeps two categories: red lies (judged deception) and orange trash (low-entropy or deviates-from-known values). They're scored separately, and Maskole drives both to zero. Clearing the orange flags meant, for example, adding deterministic time-domain audio noise so the section hash lands outside the detector's table of known values, and rebuilding the speech-synthesis voice list so a US persona doesn't leak a Chinese zh-CN TTS voice from the host machine.
Result: both the main laptop persona (dpr 1.25) and a desktop variant (dpr 1.0) measurelieCount = 0andtrashCount = 0, with all 11 dimensions rendered and independently verified twice.
"Source-level modification" is a stronger starting point than JavaScript injection — but a stronger route drivenincorrectly still loses. The goal is the stability of a clean 0-lies engine and genuine engine-level realism. That's the bar Maskole is built to.