CreepJS is the test everyone in this space quotes and almost nobody reads carefully. It's an open-source page that fingerprints your browser and then — this is the part that matters — checks whether your browser is lying. Not whether your values are unusual. Whether they're internally possible.
I spent a long stretch getting a browser through it. It passes now, mostly. I want to write down what actually moved the needle, and then be specific about the part that still catches me, because a post that ends with "and then it was perfect" is a post you shouldn't believe.
What CreepJS is really testing
Most fingerprint pages collect values and show you a hash. CreepJS collects values and then attacks them:
-
Native function integrity. If you replace
HTMLCanvasElement.prototype.toDataURLwith your own function, the replacement'stoString()no longer produces the native code string, itsnamemay be wrong, its property descriptor differs, and the prototype chain can be walked to compare. There's an entire family of these checks. - Cross-realm consistency. It asks the same questions from the main thread, from a Web Worker, and from an iframe. Each of those is a fresh set of native objects. An override that lives in one realm is absent in the others, and the disagreement is the finding.
-
Derived-value coherence. Fonts, screen metrics,
Intloutput, math implementations, audio and canvas rendering are all products of the machine underneath. It cross-checks the story they collectively tell against the one your UA claims. - Lie tallies. It doesn't just fail you; it counts how many distinct lies it caught and shows them by name. That's the most useful debugging surface in this entire field, and it's the reason I recommend the page even to people who never ship a spoofed browser.
The consequence is brutal and clarifying: every technique based on patching JavaScript from userland is visible to this page. Not "might be visible with effort." Visible by default, by design. If your approach is an init script that redefines getters before page scripts run, CreepJS will list it.
So the work moved into the engine
That's why the version that passes doesn't inject anything. The values are produced by the browser build itself, from a per-profile config file the process reads at startup. When a page asks for the GPU renderer string, nothing intercepts the call and rewrites the answer — the answer is the configured value, at the layer that would normally read the driver. There's no patched function to inspect, no descriptor mismatch, and worker threads and iframes report the same thing as the main thread because they're all asking the same underlying implementation.
The things that made the biggest difference, roughly in order:
1. Making the persona self-consistent before caring about any individual value. A profile gets one generated identity — OS, Chrome major version, GPU vendor/renderer pair, screen geometry, device pixel ratio, language list, timezone — and every surface derives from that one record. Values invented independently are how you get an NVIDIA renderer on a persona with an Intel-only screen resolution, or Intl reporting a timezone that contradicts the UA's platform.
2. Deriving network-adjacent values from measurement, not from taste. navigator.connection exposes rtt, downlink and effectiveType. Real Chrome computes all three from one observed round-trip using published thresholds. So I measure the actual round trip through the profile's proxy, round it to a grid, and run it through the same threshold table Chromium uses. The measured value is biased high — it includes DNS, connect and TLS — and I deliberately don't compensate, because a slightly pessimistic effectiveType is a real value combination, while a hand-tuned one is not.
3. Deriving locale and timezone from the exit IP. Not from the host. Not from a setting the user forgot to change. The browser looks up where its own traffic actually comes out and configures itself to match.
4. Removing automation artifacts that aren't fingerprint surface at all. The one I'm most glad I fixed: proxy authentication. The easy implementation is a small extension that answers the auth challenge. It works perfectly, and it leaves an enumerable entry in the browser's extension list — a "Proxy Auth" item in a browser that's supposed to look like a stock consumer install. Now the credentials are handled in the network layer of the engine itself: SOCKS5 sub-negotiation at the socket level, and HTTP proxy auth answered inside the browser's own auth path. No extension, no JavaScript, and the password never reaches a renderer process.
The part that still catches me
Here's the one I haven't fixed, stated plainly because you'll hit it too if you go down this road.
On a Windows host with a Chinese system locale, CreepJS can still surface a CJK UI font in the font results — while the persona claims to be a US English machine. Everything on the fingerprint enumeration side is clean: the font list is what a stock US Windows install would report. The leak comes through CSS system font keywords (menu, small-caption, status-bar). Those resolve through a different code path than the one I replaced, so the values reported are still the host's real UI font, and the page measures the rendering rather than asking for a name.
Two lessons in that, both generalizable:
- The spoofing surface and the rendering surface are different surfaces. You can win the first one completely and lose on the second, because detectors don't ask "what fonts do you have," they measure how text renders.
- The fix has to be in the same code path the value comes from. I patched something plausible-looking that turned out never to be called on that platform, and I believed it was fixed for weeks because the enumeration output looked right.
A second one from a different platform, in the same spirit. On macOS, the browser's internationalization layer takes the locale from the OS, not from the command-line language flag — so a persona that claims US English while the host is set to something else gets a contradictory Intl result. The workaround is to pass the locale in the form macOS reads. It works, and it costs you: the argument format isn't recognized as a flag by the command-line parser, so it gets treated as a URL and the browser opens a junk tab on every start. The launcher closes that tab after connecting. For a while I looked for a cleaner trick that would avoid it entirely; the one I tried broke session restore in a way that was much worse than a stray tab, and I rolled it back.
That's the actual texture of this work. Not clever bypasses — a long list of platform-specific paths, each of which will produce a contradiction if you handle it approximately.
What passing does and doesn't buy you
The most honest independent test I've seen scored several commercial anti-detect browsers on consistency and found the marketing ranking roughly inverted: two products sold as top-tier landed mid-pack, and a cheaper one topped the table. The author's own caveat is the right one to end on: CreepJS is an educational open-source project, and passing it is not the same as not getting banned.
Real production defenses layer things CreepJS never touches — TLS and HTTP/2 fingerprints below the JS layer, IP reputation, account history, behavioral models built from the first request. A profile can score beautifully and still get flagged for arriving from a datacenter IP with no history and mouse-free navigation.
What passing gets you is the elimination of an entire class of instant, cheap disqualification. It means the contradictions are gone, so when something still fails you can go look at the layers that actually decide. That's worth a lot. It just isn't a finish line, and I'd rather say so than sell you the version where it is.
Top comments (0)