Back to Knowledge Hub
Fingerprint

CreepJS Font Tables: 51 Names and 14 Possible Returns

CreepJS checks 51 font names to infer a Windows or macOS version. Executing its tables shows every answer needs a complete base set, not one stray font.

BotBrowser Team

Documentation

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 can keep a bounded record of which font families a page-owned check found while it replays a declared profile; it does not change the installed font set and cannot make that set describe a different operating system. CreepJS reads a fixed list of font names and turns the ones a page can detect into a Windows or macOS version. Running its published tables shows the lookup is stricter than the table shape suggests: every version it can name requires a complete base set of fonts, so a single stray family decides nothing.

The three Android font names in this source do not identify which embedding context exposed them. CreepJS and Android WebView signal boundaries separates page-visible values from request and optional app-bridge signals.

Watch the video

This English-language video discusses the published font-table lookup. The source lists 13 named Windows and macOS versions; its fourteenth possible return is undefined when no complete bucket matches. The audio is English. The reproducible lookup record uses synthetic font-name sets and does not claim live browser font measurement.

TL;DR

  • The published font module declares 51 font names across five tables: 14 Windows names, 18 macOS names, 9 desktop-application names, 7 Linux names and 3 Android names.
  • The 51 names are already unique: no name appears in two tables, so there is nothing to collapse before the page checks them.
  • The Windows table splits its 14 names into five version buckets, and the smallest bucket holds one name.
  • The macOS table splits its 18 names into eight buckets, and two of those buckets hold a single name.
  • A one-name bucket is necessary but never sufficient: the Windows map only has entries for keys that contain the full Windows 7 pair, so Segoe Fluent Icons alone returns nothing.
  • CreepJS uses two detection paths, document.fonts.check and a local font load, and merges both answers into one sorted list.
  • Executing the lookup gives 5 Windows answers, 8 macOS answers and one absent answer, so it can return 14 values in total.

Contents

A first panel lists five font tables with 14, 18, 9, 7 and 3 names, totalling 51 distinct names. A second shows the Windows table split into five version buckets, with the Windows 11 bucket holding one name. A third shows the macOS table split into eight buckets, two of them holding a single name. A fourth shows that a Windows answer requires the full Windows 7 pair and a macOS answer the full 10.9 pair, and that the lookup can return 14 values.

What the font tables declare

The font module is a data file with a small amount of logic on top. The probe evaluates each table as data rather than scanning its text, because the version labels are quoted keys that a text scan would count as names:

source -> https://raw.githubusercontent.com/abrahamjuliot/creepjs/master/src/fonts/index.ts
source_bytes -> 12847 sha256=ce2bcefd13e41cef79bfdf63634ad17ca7f641ee2e88c0ef720c2d38938dbf11
table:WindowsFonts -> names=14 shape=buckets
table:MacOSFonts -> names=18 shape=buckets
table:DesktopAppFonts -> names=9 shape=buckets
table:LINUX_FONTS -> names=7 shape=list
table:ANDROID_FONTS -> names=3 shape=list
fontlist -> length=51 distinct=51 duplicated=0
overlap:windows_macos -> 0
overlap:linux_android -> 0

Fifty-one names, no duplicates. That is the whole detection surface for the font half of the library. A page that runs the module asks about 51 families, and every platform conclusion the module can draw comes from which of those 51 it found.

The same public source also feeds CreepJS signal groups and how it combines them, where the font list is one group among several, and the CreepJS canvas seed cycle, which reads the generator in the canvas module.

The five tables are not alternative spellings of one list. The seven Linux names are families an Ubuntu or Chrome OS build ships, and the three Android names are a mobile set that overlaps Chrome OS rather than a desktop. Each table contributes names the others do not, and the module merges all of them once into one alphabetical list that the page checks.

Windows and macOS are close to balanced: 14 names against 18. That near-parity is misleading in a different way, because the two lookups read their tables with different rules, and the stricter rule is the one that decides whether an answer appears at all.

Why one name is never enough

The tables are not flat lists. Each version is a bucket, and the lookup asks whether any name in the bucket is present:

bucketsizes:WindowsFonts -> 7=2 8=4 10=4 11=1 8.1=3
bucketsizes:MacOSFonts -> 12=3 13=4 10.9=2 10.10=2 10.11=1 10.12=3 10.13-10.14=1 10.15-11=2
hashkeys:windows -> 11 macos=8
answers:windows -> 5 11,10,8.1,8,7
answers:macos -> 8 Ventura,Monterey,10.15-11,10.13-10.14,Sierra,El Capitan,Yosemite,Mavericks
answers:possible_returns -> 14

The Windows 11 bucket holds one name, Segoe Fluent Icons. The macOS 10.11 bucket holds one name, and so does the 10.13-10.14 bucket. Reading the buckets alone suggests that finding one of those fonts settles the version. Executing the lookup says otherwise.

Each version key is computed, joined into a sorted string, and looked up in a fixed map. Every entry in that map contains the Windows 7 label, because the Windows 7 test is the only one that demands a complete set rather than any single name. The sorted join puts 7 wherever its sort order falls, so the label's position varies while its presence does not. A font set that has Segoe Fluent Icons but not both Windows 7 names produces no match at all:

replay:windows_11_bucket_only -> undefined (input=1)
replay:windows_7_both_names -> Windows 7 (input=2)
replay:windows_7_one_name -> undefined (input=1)
sweep:windows:undefined -> 5 subsets 11 10 11+10 11+8.1 11+8
sweep:windows:Windows 11 -> 1 subsets 11+10+8.1+8

The sweep above is every subset of the four incremental buckets added to the Windows 7 pair. Five of sixteen combinations return nothing. The one combination that returns Windows 11 is the full set of all five buckets. Removing a whole bucket and removing one family from it are different events, and the probe separates them:

drop:Segoe Fluent Icons -> Windows 10
drop:Segoe MDL2 Assets -> Windows 11
drop:Segoe UI Emoji -> Windows 11
drop:Cambria Math -> macOS Ventura

A single-name bucket has no slack: losing Segoe Fluent Icons costs the Windows 11 answer. A multi-name bucket does have slack, because it stays true while any one of its names is present: Segoe MDL2 Assets sits in the four-name Windows 10 bucket and Segoe UI Emoji in the three-name 8.1 bucket, so losing either still returns Windows 11. Losing a Windows 7 name is the sharpest case, because the Windows lookup then returns nothing and the macOS fallback takes over. On a machine that also has the macOS families, the reported answer flips to macOS Ventura; on a machine with only Windows fonts it becomes undefined. Either way the Windows answer is gone after one missing family.

The macOS side is stricter still. All eight entries contain the 10.9 label, and the map has no entry for a set that has newer buckets without it:

sweep:macos:macOS Mavericks -> 1 subsets base:10.9
sweep:macos:macOS Ventura -> 1 subsets 13+12+10.15-11+10.13-10.14+10.12+10.11+10.10
sweep:macos:undefined -> 120 subsets

One hundred and twenty of the 128 subsets return nothing. Each of the eight named answers corresponds to exactly one subset, and the answer is the newest bucket present. A machine that reports only Ventura-era fonts and lacks Helvetica Neue and Geneva gets no macOS answer, even though four Ventura bucket names were found.

So the one-name buckets are real but narrow. Segoe Fluent Icons distinguishes Windows 11 from Windows 10 only after both Windows 7 names and three other buckets have already matched. The asymmetry to record is between the shape of the table and the shape of the lookup: the table looks like a set of independent OR tests, and the map behaves like a ladder that only starts once a base set is complete.

That ladder has a second consequence that a bucket count hides. Because each map key is a sorted join of every truthy entry, a set that matches a bucket the map has no key for produces a string the lookup cannot resolve. The lookup does not fall back to the nearest match; it returns nothing. The published tables happen to be closed under their own names, so every real Windows 11 set that contains all 14 Windows names, including the four Windows 8 families, still returns Windows 11; the risk lies in a table change that adds a bucket without adding its key.

The desktop-application table is checked with a different rule again, and the difference is worth stating plainly because it is the one place where a complete match is required on purpose. getDesktopApps compares each application's font set against the found list and returns the key only when every name in that set is present. Four applications therefore produce at most four results, and a partial match produces none.

What the two detection paths add

The module does not rely on one way of asking whether a font is present:

detection_paths -> 2 document.fonts.check and local font load
union -> fontsChecked then fontsLoaded, deduplicated

The document.fonts.check path asks the font set directly. document.fonts.check reports whether a family is available to the document, and the module walks the whole 51-name list through it. The local font load path constructs one object per name and waits for the load results. It collects the names whose load was fulfilled.

Two paths exist because they can disagree. A browser can report a font as available through one API and fail to load it through the other, and a page that only checked one would miss the disagreement. The union of the two answers is the module's final list, sorted and deduplicated, so the result is stable across the order the two paths returned.

A report that stores the two path lists separately can also say whether one API is systematically weaker on a machine. If the query path finds nothing while the load path finds names, the guard likely fired, and that is a different observation from a machine that genuinely has no fonts.

The document.fonts.check guard runs before the walk. Before walking the list, the module checks a deliberately random family name; when that check reports the random name as available, the module skips the per-name query and relies on the load path instead. That guard exists because a font set that claims to contain every name is not giving a useful answer, and skipping the walk avoids trusting it.

The guard is easy to miss when reading the report, because a skipped query and a failed query both leave the checked list empty. The two states are distinguishable only if the report says whether the random-name probe returned true. A reader who sees an empty checked list and a full loaded list should assume the guard fired rather than that every family failed the availability test.

A name merged by deduplication looks the same as a name that was found once, so a report should keep the two path lists separately. The lookup itself is order-independent for a different reason: the bucket tests use membership and a length comparison, and the key is built by sorting the bucket labels rather than the font list. The probe confirms it by feeding the same 32 names in reverse order:

reorder:reversed_names -> Windows 11

The two paths also fail differently. A query that throws leaves the whole module in its catch branch and returns an empty list, while a single failed load only drops that one name from the loaded list. Keeping the lists apart is what lets a reader tell a whole-module failure from one family that did not load.

Where the inference stops

The order of the two platform lookups also decides what a machine with fonts from both tables reports. getPlatformVersion tries the Windows lookup first and falls back to macOS only when Windows returns nothing, so a Mac that happens to satisfy every Windows bucket is reported as Windows. The probe makes the case concrete: a font set holding all 14 Windows names and all 18 macOS names returns Windows 11, not macOS Ventura.

The WindowsFonts and MacOSFonts tables are also a fixed snapshot of the platforms the author cared about. A new OS release that ships fonts the table does not name is invisible to the module until the table is updated, so the absence of a match is not evidence that the platform is new.

The macOS table is the only one with two-digit version labels, and the labels are grouped rather than exact: 10.13-10.14 is one bucket and 10.15-11 is another. A match therefore identifies a range, not a point release, and a report should keep the range rather than rounding it to a single version.

The lookup runs after the font list is built, so a failure in the detection stage produces an empty list and, with it, no platform answer. That matters because the absent answer is one value, not two. A page that reads an empty version should check whether the font list was empty before concluding that the machine is unrecognised, because an empty list and a non-empty list with no matching key both produce the same silence.

The published module also exposes a small number of application font sets, and each one is compared against the found list as a group:

bucketsizes:DesktopAppFonts -> Microsoft Outlook=1 Adobe Acrobat=1 LibreOffice=4 OpenOffice=3

The table is small, nine names, and each application's set is smaller still. A machine that has the application's fonts is not the same as a machine running the application, because fonts can be copied or bundled separately. The module treats a complete application font set as evidence of the application, and a report should present it that way rather than as proof that the program is installed.

A page that calls the module twice in one session can get two lists, because a font can load between the calls. The module caches nothing between runs and rebuilds the list each time, so a second reading is a fresh observation rather than a refreshed view of the first.

The combined list is what a page actually checks, so a name that appears in one platform's table is still asked about on every platform. A Windows machine is asked whether it has the macOS names too, and the answer is almost always no. That is how the tables stay independent: the page asks the union, and the platform lookups read the parts of the union they care about.

A font list can also change without the browser moving: installing a font for one user changes what that user's browser reports and leaves another user's list alone, and a package update can add names that were absent before. The platform answer follows the list, so it moves with an installation rather than with the OS release.

The 14 possible returns also set the smallest useful report. A single version field can name the answer but not the reason, and a reader who wants to check the inference needs the bucket set that matched rather than only the release it named. A report should keep the source digest and the font list in one record, because a list without the revision cannot be checked and a revision without the list cannot show what the browser found.

The 14 returns also fix what a comparison between two machines can claim. Two reports that both say Windows 11 agree only if their stored bucket sets agree, because the same answer can come from a slightly different found list. Comparing the stored lists separates a genuine difference in the installed fonts from the same fonts read twice.

Record a font reading

A font reading supports a narrow claim: this source revision, this table set, produced this list. Four fields keep it checkable:

  1. The source digest, because a change to a table changes which names a page checks and what a version answer means.
  2. The names the page found, not just the version, because the answer is a function of the whole found list and cannot be re-derived from the release name alone.
  3. Which detection path found each name, because the two paths are separate observations and the sorted union hides the distinction.
  4. The bucket sizes, because a version answer backed by one name and a version answer backed by four are not the same evidence.

Public sources

Practical conclusion

A recording that stores the matching Windows 11 bucket and both path lists can be replayed with a short script; a recording that stores only the version cannot be checked without the browser. Record the found names with the version, because the answer depends on a complete base set and cannot be re-derived from the release name alone. Read the two detection paths as separate observations, because the sorted union hides which API found a name. Treat the 14 possible returns as the size of the lookup rather than its accuracy, since a one-name bucket only discriminates after every other bucket in the map has matched. For how a library groups its font signal with the other canvas and audio groups, read CreepJS signal groups and how it combines them; for the canvas generator that shares the same module, read the CreepJS canvas seed cycle.

#CreepJS#Font Fingerprinting#Platform Version#Signal Analysis#Privacy

This article is part of the guide: What Is a Browser Fingerprint?

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.