CreepJS Signal Groups and How It Combines Them
One probe on CreepJS: nine independent group patterns become a single ordered decision string, and one group can only be caught by comparing it with another.
BotBrowser Team
Want the structured docs for Fingerprint?
This article lives in the editorial library. For step-by-step setup, reference material, and ongoing updates, jump into the docs section.
BotBrowser supports replaying an authorized same-origin fixture under a declared profile and keeping a bounded record of what each probe group reported, but it cannot make one group agree with another that describes a different surface, cannot hide a group that was blocked rather than answered, and cannot change the order in which a decision string names its reasons. This review reads CreepJS from its public source, reimplements its nine-pattern decision, and records which patterns a single group can set on its own, which one needs two groups to disagree before it fires, why the order of the nine decides which reason gets reported, and what the two server-side patterns can never contribute from inside a page.
TL;DR
- CreepJS runs more than twenty probe groups and folds their results into one nine-bit decision string, in a fixed order, alongside per-group digests.
- A clean profile sets no bit at all: the probe prints
clean -> botHash=000000000 set=0 badBot=none. - Four patterns can be set by a single group: a lied worker scope, a features-version mismatch, a replaced
Function.prototype.toString, and a blocked worker scope. - A blocked worker scope is the cheapest single tell, because it fires even when nothing lied:
worker-blocked -> botHash=000000100. - One pattern needs two groups: a font platform that disagrees with the OS the worker user agent claims.
- That cross-group disagreement does not get its own bit; it feeds the extreme-lie pattern, so
font-vs-ua-feeds-extreme -> botHash=000010000 badBot=extremeLieCount. - The order of the nine decides the reported reason: with four bits set the probe prints
botHash=110100100 badBot=liedWorkerScope, and reversing the input order changes nothing. - Two patterns are computed on a server, so from inside a page they are always zero:
server-bits -> positions=6,8 always=00.
Contents
- What the nine bits are and where they come from
- Which groups can set a bit alone
- Which bit needs two groups to disagree
- Why the order of the bits matters
- Record a group cross-check reading
What the nine bits are and where they come from
CreepJS does not emit one score. It runs a set of independent probe groups, condenses each group's output into a separate digest, and then folds nine specific patterns into an ordered decision string. The nine come from different groups, which is the whole design:
clean -> botHash=000000000 set=0 badBot=none
Nine zeros for a browser that raised nothing. Each position names a pattern drawn from a different part of the page: the worker scope's own report, the platform version implied by a font measurement, whether Function.prototype.toString still behaves like a native, whether a features list matched the declared version, an extreme count of individual lies, an excessive number of loose fingerprints, whether the worker scope was blocked at all, a crowd-blending score, and a server-side pattern.
A platform mismatch between two groups is the case why one odd signal never convicts a browser scores differently from a single thin surface. The per-group digests and the decision string answer different questions. A digest says "these two runs of the canvas group produced the same bytes", which is a stability statement about one surface. The decision string says "these nine named conditions were true", which is a statement about the whole page. A profile can have a perfectly stable canvas digest and still set a bit, because the bit comes from a different group.
That separation is worth holding onto when reading any fingerprint report. A stable per-group digest is often mistaken for a good result, and it is not the same claim. Stability means the group is reproducible; it says nothing about whether the group's output agrees with another group's.
Which groups can set a bit alone
Four of the nine patterns can be set by one group with no comparison at all. The probe runs each one against a clean baseline:
worker-scope-lied -> botHash=100000000 set=1 badBot=liedWorkerScope
features-version-lied -> botHash=000100000 set=1 badBot=outsideFeaturesVersion
proxy-toString -> botHash=001000000 set=1 badBot=functionToStringHasProxy
worker-blocked -> botHash=000000100 set=1 badBot=workerScopeIsBlocked
Each line sets exactly one bit, and each bit sits at a different position, so the string itself records which group spoke. A worker scope that reported something inconsistent sets the first position. A features list that did not match the declared browser version sets the fourth. A Function.prototype.toString that no longer behaves like a native sets the third. A worker scope that could not be created at all sets the seventh.
A worker that reports a different value from the main page is one of the few tells that no legitimate client produces, which browser API integrity across realms records in detail. The blocked-worker case is the one worth pausing on, because it fires without any lie. Nothing reported a wrong value; the page simply could not get a worker scope to answer. That makes it the cheapest single tell in the whole set: a page can be perfectly consistent on every surface it does expose and still set this bit by not exposing one. Any test that reads only the surfaces that answered will miss it.
The Function.prototype.toString position is the one a page can influence most directly, and the earlier analysis of native override lies and what a toString whitelist sees covers which edits survive that check and which wrappers pass it. The decision string here only records that the pattern fired; it does not say which edit caused it.
Which bit needs two groups to disagree
One pattern cannot be evaluated from a single group, because it exists precisely to compare two. The probe feeds it a user agent and a font platform and records what the comparison decides:
cross:agree-win -> uaOS=WINDOWS fontOS=WINDOWS fontOSBad=false
cross:agree-mac -> uaOS=APPLE fontOS=APPLE fontOSBad=false
cross:disagree-ua-mac-font-win -> uaOS=APPLE fontOS=WINDOWS fontOSBad=true
cross:disagree-ua-win-font-mac -> uaOS=WINDOWS fontOS=APPLE fontOSBad=true
cross:android-with-mac-font -> uaOS=LINUX fontOS=APPLE fontOSBad=true
When both groups name the same platform family, nothing fires. When they name different families, the comparison fires in either direction, and it also fires for an Android user agent carrying a font platform that reads as Apple. The direction does not matter, only the disagreement, which is what makes this a coherence check rather than a cutoff applied to one value.
The Android case is the interesting one for a deployment. A profile that reports an Android user agent while its font measurements resolve as a desktop Apple family sets this pattern even though neither group is individually wrong-looking. The user agent is a plausible Android string and the font platform is a plausible Apple string; they simply cannot both be true of one device.
What the pattern does with that disagreement is the part that is easy to miss:
font-vs-ua-feeds-extreme -> botHash=000010000 set=1 badBot=extremeLieCount
The cross-group disagreement does not get a bit of its own. It feeds the fifth position, the extreme-lie pattern, alongside an absolute count of individual lies. So a report that says the extreme-lie bit fired could mean a page accumulated a very large number of small inconsistencies, or it could mean one cross-group disagreement was enough. The bit does not distinguish those two cases, and a team reading it has to go back to the per-group output to know which.
That design choice is deliberate and it has a cost. Folding a qualitative disagreement into the same bit as a quantitative count means the decision string loses information the underlying groups still have. The string is a summary, and the summary is lossy in a specific, documented way.
Why the order of the bits matters
The nine positions are not equal, because the decision reports the first set bit as its reason. The probe sets four patterns at once and then repeats the same four in a different input order:
many-bits -> botHash=110100100 set=4 badBot=liedWorkerScope
many-bits-reversed-input -> botHash=110100100 set=4 badBot=liedWorkerScope
Four bits are set and the reported reason is always the leftmost one. The input order does not change the string or the reason, which means the order lives in the fixed list rather than in the order the groups happened to finish. A page that sets four patterns gets told about one of them, and that one is whichever sits furthest left.
The practical consequence is that a single reported reason does not mean a single finding. A report that names one reason alongside four set bits is telling you there are four, and it is choosing to surface the leftmost. A team that reads only the reason will under-count what the page actually did.
Two of the nine positions can never be set from inside a page:
server-bits -> positions=6,8 always=00
The excessive-loose-fingerprints and crowd-blending positions are computed where many sessions can be compared, so a browser evaluating itself reports zero for both no matter what the page does. A page-side run therefore covers seven of the nine patterns, and the two it cannot cover are the ones that need a population rather than a session.
The single-bit strings make the same point about granularity:
single:liedWorkerScope -> 100000000 position=1
single:functionToStringHasProxy -> 001000000 position=3
single:workerScopeIsBlocked -> 000000100 position=7
Three different findings, three different strings, each with exactly one set bit. The string is precise about which pattern fired and silent about how strongly. A page that sets position one because a worker scope reported one wrong value and a page that sets it because the scope disagreed with itself in six ways produce the same string.
The Windows release mapping the platform-version pattern depends on shows why that pattern is coarse as well:
win-release:0.3.0 -> 8.1
win-release:8.0.0 -> 10 (1903|1909)
win-release:13.0.0 -> 11
win-release:15.0.0 -> 11
Every platform version from 13.0.0 upward maps to Windows 11, so the pattern cannot distinguish one Windows 11 build from another. It is a family-level check, which is exactly what makes it useful for catching a mismatch and useless for identifying a build.
#Two more groups deserve a note because their outputs are the ones most often misread. The canvas group produces several separate digests rather than one, because the same drawing can be measured through a data URI, a painted surface, a text run and an emoji run, and each of those has its own stability. A report that prints one canvas value and calls the group stable has collapsed four measurements into one, and a run where three of the four matched still reads as stable.
The fonts group is the other one, and it is where the cross-group pattern draws its input. It records a platform version derived from how a font rendered rather than from any header, which is why comparing it with a user agent is meaningful: the two numbers come from different machinery, and one device cannot legitimately produce both if they disagree. When a deployment reads a font platform and finds it plausible, that is a statement about one group; it becomes a statement about the page only after the comparison.
The worker scope group is the third, and it is special because it can fail in two different ways. A scope that answered with an inconsistent value is a lie, while a scope that never answered is a block. The decision string has a position for each, which is unusual in this set: most patterns share a position with a related condition, and only the worker scope keeps its two failures apart.
A fourth group earns a paragraph because it is the one a profile change most often disturbs without anyone noticing. The features group compares the feature set a browser exposes against the version its user agent declares, so it answers a question about the pair rather than about either side. A profile that updates one of the two and not the other sets the fourth bit while every individual feature still behaves correctly, because nothing is broken; the two values simply stopped describing the same release.
That pattern also explains why the decision string is more useful than a per-group report for a release check. A per-group report shows twenty stable groups, each of which is internally consistent, and a reader has to compare them by hand to find the pair that no longer agrees. The decision string does that comparison for you, at the cost of telling you only that some pair disagreed rather than which one.
The fifth position has the same shape at a different scale. The extreme-lie pattern fires either from a large count of individual lies or from one cross-group disagreement, so it answers "was this page badly wrong somewhere" rather than "how wrong was it". A page with one serious disagreement and a page with fifty small ones produce the same bit, and the only way to tell them apart is to read the group output that the bit was computed from.
Record a group cross-check reading
A worked entry makes the four fields concrete. A run that returned botHash=110100100 with badBot=liedWorkerScope is recorded as the string plus the commit plus the per-group output for the worker scope, the features list, the Function.prototype.toString check and the font measurement, and it names the two positions that were not evaluated. A reader can then tell that four patterns fired, that the reported reason is position one, and that the font-versus-user-agent comparison contributed through position five rather than a bit of its own.
A group cross-check reading supports a narrow claim: this library version, these groups, produced this decision string. Four fields keep it checkable:
- The library commit and the ordered pattern list, because the positions and their meaning are code, and a reordering silently changes which reason is reported.
- The per-group output next to the decision string, because the string collapses a qualitative disagreement and a quantitative count into one bit and the groups still hold the distinction.
- Which patterns were evaluated and which were not, because two of the nine need a population and are always zero in a page-side run.
- The set bits as a string rather than a count, because four set bits with one reported reason and one set bit are different findings and a count cannot tell them apart.
One more practical note about reading the string over time. Because the positions are a fixed list in source, a library update can insert a pattern and shift every later position by one. A stored decision string from one version and a fresh one from another are then not comparable even though both are nine characters, and a comparison that lines them up will report differences that are purely a change in the list. Recording the commit alongside the string is what keeps that comparison honest.
Public sources
- CreepJS
- CreepJS bot decision and hashing module
- CreepJS platform classification helpers
- MDN: Worker
- MDN: User-Agent Client Hints
- BotBrowser: automation consistency reference
Practical conclusion
Read a CreepJS result as an ordered string of named patterns rather than a score, because the string says which group spoke and the score would only say how many. Treat a blocked worker scope as a finding in its own right even though nothing lied, since it is the cheapest single pattern and the easiest to overlook. Expect the font-versus-user-agent pattern to fire on a disagreement in either direction, and remember it feeds the extreme-lie bit rather than a bit of its own, so the reason alone cannot tell a qualitative mismatch from a large count of small ones. For the integrity check that one of the nine positions depends on, read native override lies and what a toString whitelist sees; for how a decision built from several layers is scored and gated, read why one odd signal never convicts a browser.
This article is part of the guide: What Is a Browser Fingerprint?
Related Articles
How to Read Browser Fingerprint Signals
FingerprintAbort Signal Reason Composition and Late Listeners
FingerprintWhat Is a Browser Fingerprint?
FingerprintProxy Traps and the Invariants the Runtime Enforces
FingerprintDate String Parsing: Local and UTC Boundaries
FingerprintDataView: Endianness, Alignment and Bounds
Take BotBrowser from research to production
The guides cover the model first, then move into cross-platform validation, isolated contexts, and scale-ready browser deployment.