Most fingerprinting scripts collect. CreepJS verifies. That distinction is the whole game. A collector reads canvas.toDataURL() and hashes it. A verifier draws a known pixel and checks that you read back exactly what was written. That second class of test does not care whether your noise is random per-call or a perfectly deterministic pure function of (seed, input). It catches the shape of the lie, not its variance. Below are the four invariant classes that survive deterministic noise, with the exact mechanisms from the CreepJS source.
Red lies vs orange trash: know which metric you’re chasing
Half the confusion in anti-detect forums comes from conflating two different verdicts, so fix the vocabulary before the four classes.
CreepJS renders two visually distinct flags:
- Red
.liesis a judged lie. Some invariant that must hold was violated. This is the hard metric. The target islieCount = 0. - Orange
.bold-failisLowerEntropy/sendToTrash: your value is off a known-good cluster or suspiciously low-entropy. It’s a secondary signal, not an accusation of tampering.
You can pass with orange flags. You cannot pass with red ones. Everything below is the red set: the invariants that produce a judged lie regardless of how stable your spoof is.
A prior generation of anti-detect kernels learned the first, easier lesson. Don’t randomize per call. If getBoundingClientRect() returns 0.246032417… on the first read and 0.246032432… on the second, CreepJS’s repeat-equality test flags it instantly, because the noise isn’t even a function of the input, it’s a function of the wall clock. Making noise a pure hash(seed, input) → output fixes that. But a kernel that clears every per-call test can still light up four red lies.
| Class | Invariant being checked | CreepJS mechanism | What a naive deterministic-noise spoof does wrong |
|---|---|---|---|
| 1. Blank invariants | Data that must be blank stays blank | Silent audio must be all-zero; clearRect region must be all-zero; a 0×0 element’s rect must be all-zero |
Applies noise unconditionally, perturbing zeros into non-zeros |
| 2. Write-read consistency | getImageData returns exactly the bytes you wrote |
fillRect a known opaque pixel, getImageData(x,y,1,1) must match byte-for-byte |
Adds readback noise on the getImageData path |
| 3. Cross-path consistency | One signal changed, so all its side channels change | matchMedia('(device-width:…)') must agree with screen.width; getChannelData must agree with copyFromChannel |
Overrides the JS getter but not the media-query / secondary path |
| 4. DPR-gated hardcoded hash | A rotated box’s bounding rect hashes to a known constant when dpr===1 |
Hash of a 100×100 rotate(45°) box compared to '9d9215cc' |
Perturbs the rect at dpr===1, breaking the hardcoded hash |
Class 1. Blank invariants: never noise something that must be zero
The cleanest tell. CreepJS deliberately constructs inputs whose correct output is entirely blank, then checks that you left them blank. A noise function that runs unconditionally cannot help but corrupt them.
Audio. hasFakeAudio() renders an oscillator at frequency = 0, pure silence, through an OfflineAudioContext. The rendered buffer’s getChannelData must be all zeros. The check is blunt:
// paraphrase of hasFakeAudio()
const data = renderedBuffer.getChannelData(0);
const distinct = '' + [...new Set(data)];
if (distinct !== '0') {
// more than one distinct sample value in pure silence
return 'audio is fake';
}
If your audio noise added even a single non-zero sample, the Set has more than one member and the string isn’t '0'. There’s a second trap in the same family: the first ~100 samples of any rendered buffer must be zero, because they fall in the DynamicsCompressor lookahead delay region where no signal has arrived yet. Noise sprayed across the whole buffer corrupts that quiet prefix too.
Canvas. After clearRect, getImageData(0, 0, 8, 8) must be all zeros. CreepJS does Math.max(...data) > 0 → 'pixel data modified'. A cleared region is transparent black; any noise there is a lie.
DOMRect. A deliberately-created 0×0 “ghost” element must return {x, y, w, h, left, right, top, bottom} all zero. Perturb it and you get unknown ghost dimensions.
The fix pattern is the same across all three. Skip blanks. The noise function guards its own input.
FpPerturbAudio: if (sample == 0.0) continue; // leave silence silent
FpPerturbRGBA8: if (alpha == 0) continue; // leave transparent pixels alone
FpPerturbRectF: if (w <= 0 || h <= 0) return; // leave empty rects at zero
Blank stays blank; real data still moves. Your fingerprint still differs across profiles. You just don’t touch the pixels, samples and rects that are supposed to be nothing.
Class 2. Write-read consistency: getImageData must be a mirror
This one kills an entire naive strategy: “add ±1 to every pixel channel on read.” CreepJS’s getPixelMods() writes a known opaque pixel with fillRect, then reads it back at 1×1 and compares byte-for-byte:
// paraphrase of getPixelMods()
ctx.fillStyle = 'rgb(r,g,b)'; // known opaque color
ctx.fillRect(x, y, 1, 1);
const [pr, pg, pb, pa] = ctx.getImageData(x, y, 1, 1).data;
if (pr !== r || pg !== g || pb !== b || pa !== 255) {
// you read back something you did not write
flag('pixel data modified');
}
At a 1×1 read there’s nowhere for per-pixel noise to hide. The comparison runs directly between the fillStyle you supplied and the bytes getImageData handed back. Any readback perturbation, deterministic or not, fails.
The correct architecture noises the encode path only: toDataURL / toBlob, i.e. the EncodeImage step and leaves getImageData returning genuine pixels. CreepJS does not decode the data URI back into pixels to compare, so toDataURL-based fingerprints are still perturbed and cross-profile uniqueness is preserved, while getPixelMods() sees a faithful mirror and reports zero mods. The cost is honest: pure getImageData-based fingerprinting scripts (a minority) see your real pixels. That’s the price of lieCount = 0 and it’s worth paying.
Class 3. Cross-path consistency: change one signal, change all its side channels
A fingerprint value usually has more than one road into the page. Override one road and forget the others and CreepJS reads the disagreement directly.
Screen. The obvious road is Screen.width / Screen.height. The forgotten road is CSS media queries. matchMedia('(device-width: 1536px)') does not call the JS getter. It goes through Blink’s MediaValues::CalculateDeviceWidth, which reads the real ScreenInfo (in a headless-derived config, often 800). Override the getter alone and CreepJS logs failed matchMedia: the JS says 1536, the media query says 800. The same split exists for devicePixelRatio versus (resolution: Xdppx).
// CreepJS-style cross-check
const jsW = screen.width; // JS getter
const mqW = matchMedia(`(device-width:${jsW}px)`).matches; // MediaValues path
if (!mqW) flag('failed matchMedia');
Audio. getChannelData(0) and copyFromChannel read the same backing store. Perturb them on two separate code paths and they disagree, giving samples mismatch. Perturb the one shared buffer in place, once and they agree for free.
The fix synchronizes at the source, not at each getter. For screen, that meant adding MediaValues::CalculateDeviceWidth/Height/DevicePixelRatio hooks that read the same persisted screen.* values the JS API returns, so both roads leave from the same place. For audio, mutate one buffer and let every reader see it.
Class 4. The DPR-gated hardcoded hash: why your persona must not be dpr === 1
This is the subtlest and it forces a persona design decision rather than a code fix.
When devicePixelRatio === 1, CreepJS builds a 100×100 element, applies transform: rotate(45deg), hashes the resulting bounding rect and compares it to a hardcoded constant, '9d9215cc'. That constant is what a genuine, un-noised engine produces. Any subpixel perturbation of that box’s rect breaks the hash and you cannot special-case just this one box out of your noise, because you don’t know at perturbation time which rect is the probe.
The escape hatch lives in CreepJS itself: when dpr !== 1, the entire rotate check is skipped. So DOMRect noise is only safe on a non-integer DPR persona. A well-built kernel gates this automatically. The persona config returns a rect seed of 0 (rect noise off) unless the configured DPR is explicitly non-1, so a dpr = 1 desktop persona gets no rect noise rather than one guaranteed lie.
The recommended persona resolves all of this at once:
1536×864 @ dpr 1.25, that is 1920×1080 at 125% scaling, the single most common Windows laptop fingerprint on the web.
At dpr = 1.25 the rotate/hardcoded-hash check is skipped entirely and the remaining DOMRect invariants (the math relations right, left == width, bottom, top == height and the repeat-equality A == B test) all pass naturally under coherent deterministic noise, because those are preserved by a consistent per-rect offset. You get rect noise and zero lies and you land in the largest anonymity set on Windows instead of a rare one.
The orange tier: two trash flags worth clearing
Red lies are the pass/fail line but the orange LowerEntropy flags are worth closing because they mark you as off the known-good cluster, which is its own weak signal. Two are instructive because the fix is, counterintuitively, to add more deterministic noise, not less.
Audio LowerEntropy.AUDIO. CreepJS keys a known[[gain, freqSum, timeSum]] lookup. If your perturbed sampleSum lands inside a known cluster while the pattern key matches, you get flagged. The fix hooks RealtimeAnalyser::GetFloatTimeDomainData and adds deterministic time-domain noise, which shifts timeSum so the pattern key becomes unknown, known[pattern] is undefined and the check is simply skipped.
Audio compressor diff. A character-level diff driven by KnownAudio[compressorGainReduction]. Hooking DynamicsCompressorNode::reduction() with a per-profile offset (magnitude in [0.05, 0.2] dB, enough to leave the known-value cluster) makes KnownAudio[reduction] undefined, so CreepJS shows a plain value with no highlighted diff.
Leaving a known cluster by a deterministic, in-range amount is safer than sitting exactly on it.
voiceLangMismatch: the leak that survives everything
One more single-layer tell worth naming because it defeats otherwise-perfect personas. voiceLangMismatch compares the speechSynthesis default voice language against your Intl locale. A Chinese-Windows host exposes zh-CN SAPI voices; if your persona is en-US, CreepJS sees zh-CN voice + en-US locale and flags a genuine “American persona leaking Chinese TTS” break. It’s easy to miss: headless SAPI voices load asynchronously, so a short 3 to 11s probe often sees zero voices, while a full 90s CreepJS run reaches the speech section after they’ve loaded and catches it. The liarjs.dev scanner checks the same invariant directly:
// our scoring model, check 27
if (vfam && lfam && vfam !== lfam)
add('voice-locale', 'Speech voice vs locale', 'warn',
`Default speech voice language "${client.voices.defaultLang}" doesn't ` +
`match locale "${loc}" - may reveal the real host OS language.`, 8);
The fix rebuilds the voice list per persona at OnSetVoiceList so the default voice’s language matches the locale.
Benchmark reality: read the relative gap, not the absolute score
It’s tempting to treat a CreepJS trust percentage as a grade. Don’t. The Web Scraping Club’s independent “Anti-Detect Royal Rumble” scored commercial anti-detect browsers on CreepJS trust and found the numbers are only meaningful relative to a real baseline: a genuine Mac + Chrome scores only ~66% trust. Against that ceiling, GoLogin topped the field at ~64% and Incogniton sat at the bottom near ~14%. A “66%” is not a failing grade. It’s roughly what an honest browser gets. Chase the gap to a real device, not 100%.
And keep the ceiling itself in perspective:
Passing CreepJS is necessary, not sufficient. Real business sites still ban on proxy reputation and behavior. (CreepJS author’s own caveat, echoed by The Web Scraping Club)
CreepJS is explicitly an educational project. lieCount = 0 means you cleared the engine-consistency bar. It says nothing about your proxy’s ASN reputation, your mouse dynamics, your request cadence or the dozen commercial signals (FingerprintJS Smart Signals, TLS/JA4 fingerprints) that real anti-fraud stacks weigh alongside it.
Takeaway
Deterministic noise clears exactly one class of CreepJS check, the repeat-equality tell and nothing more. The four that remain are invariants, not variances: don’t noise data that must be blank; keep getImageData a faithful mirror and noise only the encode path; when you change a signal, change every side channel it feeds; and never let DOMRect noise run at dpr === 1. Build a 1536×864 @ dpr 1.25 persona, guard your noise functions on their inputs, synchronize at the source and you reach lieCount = 0. Then remember what that number does and does not buy you.