Two Leaks That Need No GPU

Canvas and WebGL identify a machine because the GPU renders pixels differently on every one. Fonts and Intl identify a machine for a cheaper reason: no two operating systems ship the same fonts, and no two operating systems format dates and numbers the same way. No GPU is involved in either case, which is what makes them useful when the obvious signals are already under your control.

These two are also the ones most often spoofed badly, because both have a small surface that looks easy to fake. Neither is: the set is the signal, not the individual value.

Enumerating Fonts: Three Techniques

A page cannot ask which fonts are installed. It has to test them, one candidate at a time, by rendering text and measuring. The three classic techniques differ only in what they measure:

Technique Mechanism Precision
DOM offset measurement a span in the candidate font next to a fallback, compare offsetWidth/offsetHeight works for any font; slower
canvas measureText ctx.font = "72px Candidate" then ctx.measureText(text).width fastest, batchable, sub-pixel
list probing render a list of known font names in one pass, read which changed the width one layout, many fonts

The logic is a fallback comparison. Measure the string in the candidate font; if the width matches the measurement in a known fallback (usually the default sans-serif, set to a font the page knows is absent), the candidate is not installed and the fallback was substituted. Any difference means the font is present.

document.fonts.check() and the CSS Font Loading API give a cheaper but less reliable answer, and browsers have been tightening them; measurement is what the collectors still use. A hardened browser that blocks the font list changes these results, which is itself a signal, covered in Hardening Firefox and WebKit Profiles.

The Font List Is the Signal

No single font identifies anyone. The set does, because the set is a product of the OS, the applications installed, and the user's own choices, and the space of real font sets is far smaller than the space of possible ones.

What each platform ships is well known:

Platform Baseline families
Windows Arial, Calibri, Cambria, Consolas, Courier New, Georgia, Segoe UI, Tahoma, Times New Roman, Trebuchet MS, Verdana, plus whatever Office and games install
macOS Arial, Courier New, Georgia, Helvetica, Helvetica Neue, Menlo, Monaco, Palatino, Times New Roman, plus the Microsoft fonts Office installs
Linux (fontconfig) DejaVu Sans/Serif/Mono, Liberation Sans/Serif/Mono, Noto Sans/Serif, and a CJK font only if one was installed
Android Roboto, Noto Sans, Droid Sans, Cutive Mono, plus per-OEM additions
iOS Helvetica, Helvetica Neue, Menlo, Courier, Georgia, Times New Roman, Avenir, Arial

The check a server can run is not "is this list plausible" but "is this list consistent with the OS the User-Agent claims". A Windows profile containing Helvetica Neue and Avenir but no Calibri or Segoe UI is describing a Mac or a fabricated list. A Linux profile with Roboto and no DejaVu Sans is describing an Android phone. Because fonts are enumerable, a missing expected font is as damning as a present impossible one, and collectors weight absence as heavily as presence.

Metrics: Same Name, Different Widths

Even with identical font names, the rendered metrics differ per platform, because the rasteriser, the hinting mode and the subpixel layout all differ. The same string in the same nominal family measures differently on each:

Platform and family Width of a 16px test string
Windows / Segoe UI 71.5px
macOS / Helvetica 68.2px
Linux / DejaVu Sans 76.4px
Android / Roboto 70.1px
iOS / Helvetica 68.2px

Two of those agree and three do not, which is the point: name-level agreement does not imply metric-level agreement, so a spoofed font list has to be accompanied by a spoofed set of widths. That is much harder than returning a fixed string, because the widths must be self-consistent across every test string the collector uses, in the right font, at the right size and weight. The same reasoning applies to the measureText family of probes in Inside Open-Source Fingerprint Collectors.

CSS generic families leak the same thing with no configuration at all. sans-serif resolves to Segoe UI on Windows, Helvetica on macOS, DejaVu Sans on Linux and Roboto on Android; monospace resolves to Consolas, Menlo, DejaVu Sans Mono and Droid Sans Mono respectively. A page that sets font-family: monospace and measures the result has learned which platform it is on, from a rule the page author never thought about.

Intl: The Native Locale Layer

Intl is implemented natively, not in JavaScript, which is the single most important fact about it. It wraps ICU, the Unicode library that ships inside the browser binary, along with the browser's own time-zone database. A page can read from it:

  • Intl.DateTimeFormat().resolvedOptions() gives the resolved locale, calendar, numbering system, time zone and hour cycle. The time zone is the famous one, and the rest is just as identifying.
  • Intl.DateTimeFormat.supportedLocalesOf([...]) returns which of a long probe list the build actually supports. A Chromium build with full ICU returns a large set; a small-ICU build returns a much smaller one. The set is the signature of the ICU build, not the answer for the current locale.
  • Intl.NumberFormat().format(...) reveals grouping separators, decimal marks, digit shapes and numbering systems: 1,234.56 in en-US, 1.234,56 in de-DE, 1 234,56 in fr-FR, and non-Latin digits under ar-EG with arab numbering.
  • formatToParts() returns the parts array, so a page gets the weekday and month names as strings in the page's language, and the pattern order.
  • formatRange() handles a two-value range and picks a shared pattern, which differs from the single-value pattern in ways that reveal the ICU version.
  • Intl.Collator, Intl.Segmenter and Intl.RelativeTimeFormat expose collation tables, grapheme-break rules and plural-category rules, all versioned with ICU.

Formatting the same instant in the same locale across two visits is the stability test. A browser whose time-zone database was updated by an OS patch will start formatting a zone that did not exist last month, which is why collectors treat a changed Intl result as a new device rather than an update.

The ICU Version Is a Version Fingerprint

Chromium pins an ICU version per release, and a major ICU bump changes plural rules, week-start conventions, time-zone aliases and locale data. Firefox builds against system ICU on Linux and a bundled one elsewhere. Safari uses Apple's own ICU-derived data with Apple's own conventions. So the combination of (browser major version, ICU major version, time-zone database vintage) is a triple that narrows to a fairly small set, and any of the three can be read from Intl output or inferred from formatting edge cases.

The practical version: a profile that claims Chrome 124 and formats a date according to ICU 71 conventions is wrong, and the check costs one call. A collector can also probe specific edge cases that changed in a known release, which makes the version check precise rather than a guess.

The Override That Does Nothing

The trap worth naming: overriding navigator.language in JavaScript does not change Intl output. navigator.language is a JavaScript-visible property, so it can be redefined. Intl.DateTimeFormat is a native binding; the locale it resolves comes from the browser's own settings and ICU, and redefining Intl in the page replaces the whole object for script that reads window.Intl, but the collector can also hold a reference obtained before the override, read it from an iframe, or simply compare navigator.language against Intl.DateTimeFormat().resolvedOptions().locale and see that one was changed and the other was not.

So a locale override that does not also override the formatting results is a contradiction in a single comparison. The fix is not to patch navigator harder; it is to make the profile's locale, its Intl output, its Accept-Language header and its time zone agree, which is the device profile consistency requirement in its most concrete form.

A Decision Table for Font and Intl Claims

The code below models the check a server runs: given a claimed platform, a claimed language list, a claimed time zone and an observed ICU-style locale list, it reports which combinations are plausible and which are contradictions. It is deliberately a small, hand-written reference, the same shape as the geo lesson's decision table.

# Font and Intl consistency check: given a claimed platform, language, timezone
# and an observed ICU-style locale list, decide which combinations are plausible.

# Per platform: the locales a stock install ships, the scripts its system fonts
# cover, and the core font families that are always present.
PLATFORMS = {
    "Windows 11": {
        "scripts": ["Latn", "Grek", "Cyrl", "Arab", "Hebr", "Deva", "Thai", "Jpan",
                    "Kore", "Hans", "Hant"],
        "locales": ["en-US", "en-GB", "de-DE", "fr-FR", "es-ES", "it-IT", "pt-BR",
                    "nl-NL", "pl-PL", "ru-RU", "tr-TR", "ja-JP", "ko-KR", "zh-CN",
                    "zh-TW", "th-TH", "ar-EG", "he-IL", "hi-IN", "cs-CZ", "sv-SE",
                    "da-DK", "nb-NO", "fi-FI", "uk-UA", "hu-HU", "ro-RO", "el-GR"],
        "core_fonts": ["Arial", "Calibri", "Cambria", "Consolas", "Courier New",
                       "Georgia", "Segoe UI", "Tahoma", "Times New Roman",
                       "Trebuchet MS", "Verdana"],
    },
    "macOS 14": {
        "scripts": ["Latn", "Grek", "Cyrl", "Arab", "Hebr", "Deva", "Thai", "Jpan",
                    "Kore", "Hans", "Hant"],
        "locales": ["en-US", "en-GB", "de-DE", "fr-FR", "es-ES", "it-IT", "pt-BR",
                    "nl-NL", "pl-PL", "ru-RU", "tr-TR", "ja-JP", "ko-KR", "zh-CN",
                    "zh-TW", "th-TH", "ar-EG", "he-IL", "hi-IN", "cs-CZ", "sv-SE",
                    "da-DK", "fi-FI", "uk-UA", "hu-HU", "el-GR", "ca-ES", "hr-HR"],
        "core_fonts": ["Arial", "Courier New", "Georgia", "Helvetica",
                       "Helvetica Neue", "Menlo", "Monaco", "Palatino",
                       "Times New Roman", "Trebuchet MS", "Verdana"],
        "cjk_font": "Yu Gothic",
    },
    "Linux (Debian, fontconfig)": {
        "scripts": ["Latn", "Grek", "Cyrl", "Jpan", "Hans"],
        "locales": ["en-US", "de-DE", "fr-FR", "es-ES", "it-IT", "pt-BR", "nl-NL",
                    "pl-PL", "ru-RU", "ja-JP", "zh-CN", "uk-UA", "cs-CZ", "sv-SE"],
        "core_fonts": ["DejaVu Sans", "DejaVu Serif", "Liberation Sans",
                       "Liberation Serif", "Noto Sans", "Noto Serif"],
        "cjk_font": None,                        # no CJK font in a base install
    },
    "Android 13": {
        "scripts": ["Latn", "Grek", "Cyrl", "Arab", "Hebr", "Deva", "Thai", "Jpan",
                    "Kore", "Hans", "Hant"],
        "locales": ["en-US", "en-GB", "de-DE", "fr-FR", "es-ES", "it-IT", "pt-BR",
                    "nl-NL", "pl-PL", "ru-RU", "tr-TR", "ja-JP", "ko-KR", "zh-CN",
                    "zh-TW", "th-TH", "ar-EG", "hi-IN", "id-ID", "vi-VN", "uk-UA", "he-IL"],
        "core_fonts": ["Roboto", "Noto Sans", "Droid Sans", "Cutive Mono"],
        "cjk_font": "Noto Sans CJK JP",
    },
    "iOS 17": {
        "scripts": ["Latn", "Grek", "Cyrl", "Arab", "Hebr", "Deva", "Thai", "Jpan",
                    "Kore", "Hans", "Hant"],
        "locales": ["en-US", "en-GB", "de-DE", "fr-FR", "es-ES", "it-IT", "pt-BR",
                    "nl-NL", "pl-PL", "ru-RU", "tr-TR", "ja-JP", "ko-KR", "zh-CN",
                    "zh-TW", "th-TH", "ar-EG", "hi-IN", "id-ID", "vi-VN", "uk-UA", "he-IL"],
        "core_fonts": ["Helvetica", "Helvetica Neue", "Menlo", "Courier",
                       "Georgia", "Times New Roman", "Avenir", "Arial"],
        "cjk_font": "Hiragino Sans",
    },
}

# script subtags per language, so a language can be checked against the platform
# without a full CLDR table
LANG_SCRIPT = {
    "ja": "Jpan", "ko": "Kore", "zh": "Hans", "ar": "Arab", "he": "Hebr",
    "hi": "Deva", "th": "Thai", "el": "Grek", "ru": "Cyrl", "uk": "Cyrl",
    "bg": "Cyrl", "sr": "Cyrl", "en": "Latn", "de": "Latn", "fr": "Latn",
    "es": "Latn", "it": "Latn", "pt": "Latn", "nl": "Latn", "pl": "Latn",
    "tr": "Latn", "cs": "Latn", "sv": "Latn", "da": "Latn", "nb": "Latn",
    "fi": "Latn", "hu": "Latn", "ro": "Latn", "id": "Latn", "vi": "Latn",
}

# Time zones grouped by region, so a language and a zone can be compared without
# shipping the full IANA list.
ZONE_REGION = {
    "America": ["America/New_York", "America/Chicago", "America/Denver",
                "America/Los_Angeles", "America/Toronto", "America/Mexico_City",
                "America/Sao_Paulo", "America/Bogota"],
    "Europe": ["Europe/London", "Europe/Dublin", "Europe/Lisbon", "Europe/Madrid",
               "Europe/Paris", "Europe/Brussels", "Europe/Amsterdam",
               "Europe/Berlin", "Europe/Zurich", "Europe/Vienna", "Europe/Prague",
               "Europe/Warsaw", "Europe/Rome", "Europe/Stockholm", "Europe/Oslo",
               "Europe/Helsinki", "Europe/Athens", "Europe/Bucharest",
               "Europe/Budapest", "Europe/Kyiv", "Europe/Moscow"],
    "Asia": ["Asia/Tokyo", "Asia/Seoul", "Asia/Shanghai", "Asia/Hong_Kong",
             "Asia/Taipei", "Asia/Singapore", "Asia/Kolkata", "Asia/Dubai",
             "Asia/Jakarta", "Asia/Bangkok", "Asia/Jerusalem", "Asia/Manila",
             "Asia/Ho_Chi_Minh"],
    "Africa": ["Africa/Lagos", "Africa/Cairo", "Africa/Nairobi",
               "Africa/Johannesburg", "Africa/Casablanca"],
    "Oceania": ["Australia/Sydney", "Australia/Melbourne", "Australia/Perth",
                "Pacific/Auckland"],
    "UTC": ["UTC", "Etc/UTC", "Etc/GMT"],
}

# Where a base language is normally spoken. A language outside its own regions is
# not automatically wrong, but it is worth a note.
LANG_REGION = {
    "en": {"America", "Europe", "Oceania", "Africa", "Asia"},
    "de": {"Europe"}, "fr": {"Europe", "Africa"}, "es": {"America", "Europe"},
    "it": {"Europe"}, "pt": {"America", "Europe"}, "nl": {"Europe"},
    "pl": {"Europe"}, "ru": {"Europe", "Asia"}, "tr": {"Europe", "Asia"},
    "ja": {"Asia"}, "ko": {"Asia"}, "zh": {"Asia"}, "th": {"Asia"},
    "ar": {"Africa", "Asia"}, "he": {"Asia"}, "hi": {"Asia"},
    "id": {"Asia"}, "vi": {"Asia"}, "uk": {"Europe"},
    "cs": {"Europe"}, "sv": {"Europe"}, "da": {"Europe"},
    "nb": {"Europe"}, "fi": {"Europe"}, "hu": {"Europe"},
    "ro": {"Europe"}, "el": {"Europe"},
}


def region_of(zone):
    for region, zones in ZONE_REGION.items():
        if zone in zones:
            return region
    return None


def base(tag):
    return tag.split("-")[0]


def ships(tag, spec):
    # A locale is shipped if the exact tag is, or the platform has that language.
    if tag in spec["locales"]:
        return True
    return any(base(locale) == tag for locale in spec["locales"])


def find_contradictions(platform, languages, zone, observed_locales):
    # Return every check that fails. An empty list means self-consistent.
    spec = PLATFORMS.get(platform)
    if spec is None:
        return [f"unknown platform {platform!r}"]

    problems = []
    for tag in observed_locales:
        if not ships(tag, spec):
            problems.append(f"ICU locale {tag!r} is not in a stock {platform} build")

    if not languages:
        return problems + ["navigator.languages is empty"]

    for tag in languages:
        if not ships(tag, spec):
            problems.append(f"claimed language {tag!r} is not in a stock {platform} build")
        script = LANG_SCRIPT.get(base(tag))
        if script and script not in spec["scripts"]:
            problems.append(f"{tag!r} needs the {script} script, {platform} lacks it")
        elif script in ("Jpan", "Kore", "Hans", "Hant") and not spec["cjk_font"]:
            problems.append(f"{tag!r} needs a CJK font, {platform} ships none by default")

    region = region_of(zone)
    if region is None:
        problems.append(f"time zone {zone!r} is not in the reference list")
    else:
        for tag in languages:
            allowed = LANG_REGION.get(base(tag), set())
            if allowed and region not in allowed:
                problems.append(f"{tag!r} paired with a {region} time zone")

    return problems


PROFILES = [
    ("Windows 11", ["en-US", "en"], "Europe/Berlin",
     ["en-US", "de-DE", "fr-FR", "ja-JP", "zh-CN", "ar-EG", "pl-PL"],
     "a German Windows 11 desktop"),
    ("Windows 11", ["en-US", "en"], "Europe/Berlin",
     ["en-US", "de-DE", "fr-FR", "he-IL", "th-TH", "bn-IN", "ta-IN", "sw-KE"],
     "a Windows profile with locales Windows does not ship"),
    ("macOS 14", ["en-GB", "en"], "Australia/Sydney",
     ["en-US", "en-GB", "de-DE", "fr-FR", "ja-JP", "ko-KR", "zh-CN", "th-TH",
      "ar-EG", "hi-IN", "id-ID", "vi-VN", "uk-UA"],
     "a stock macOS 14 Safari profile"),
    ("Linux (Debian, fontconfig)", ["en-US", "en"], "America/New_York",
     ["en-US", "de-DE", "fr-FR", "es-ES", "it-IT", "pt-BR", "nl-NL", "pl-PL",
      "ru-RU", "ja-JP", "zh-CN", "uk-UA", "cs-CZ", "sv-SE"],
     "a Debian container with a full desktop locale set"),
    ("Android 13", ["en-US", "en"], "Asia/Tokyo",
     ["en-US", "de-DE", "fr-FR", "es-ES", "it-IT", "pt-BR", "nl-NL", "pl-PL",
      "ru-RU", "tr-TR", "ja-JP", "ko-KR", "zh-CN", "zh-TW", "th-TH", "ar-EG",
      "hi-IN", "id-ID", "vi-VN", "uk-UA"],
     "a stock Android 13 Chrome profile on a Tokyo clock"),
    ("iOS 17", ["ja-JP", "ja"], "Asia/Tokyo",
     ["en-US", "de-DE", "fr-FR", "es-ES", "it-IT", "pt-BR", "nl-NL", "pl-PL",
      "ru-RU", "tr-TR", "ja-JP", "ko-KR", "zh-CN", "zh-TW", "th-TH", "ar-EG",
      "hi-IN", "id-ID", "vi-VN", "uk-UA"],
     "a stock iOS 17 Safari profile"),
    ("Linux (Debian, fontconfig)", ["ja-JP", "ja"], "Asia/Tokyo",
     ["en-US", "de-DE", "fr-FR", "es-ES", "it-IT", "pt-BR", "nl-NL", "pl-PL",
      "ru-RU", "ja-JP", "zh-CN", "uk-UA", "cs-CZ", "sv-SE"],
     "a container claiming Japanese, which needs fonts Debian does not ship"),
    ("iOS 17", ["he-IL", "he"], "Asia/Jerusalem",
     ["en-US", "de-DE", "fr-FR", "es-ES", "it-IT", "pt-BR", "nl-NL", "pl-PL",
      "ru-RU", "tr-TR", "ja-JP", "ko-KR", "zh-CN", "zh-TW", "th-TH", "ar-EG",
      "hi-IN", "id-ID", "vi-VN", "uk-UA"],
     "a Hebrew iPhone on a Jerusalem clock"),
    ("Windows 11", ["en-US", "en"], "Asia/Kolkata",
     ["en-US", "ja-JP", "ko-KR", "ar-EG", "he-IL", "th-TH", "bn-IN", "ta-IN",
      "te-IN", "ml-IN", "gu-IN", "kn-IN", "mr-IN", "pa-IN", "ur-IN", "fa-IR",
      "sw-KE", "am-ET", "yo-NG", "zu-ZA", "xh-ZA"],
     "a hard-coded Windows list paired with Africa and South Asia locales"),
    ("Windows 11", ["en-US", "en"], "UTC",
     ["en-US", "de-DE", "fr-FR"],
     "a bare-bones profile with a default clock"),
]


def main():
    print(f"{len(PROFILES)} profiles checked against {len(PLATFORMS)} platform sets\n")
    flagged = 0
    for platform, languages, zone, locales, note in PROFILES:
        problems = find_contradictions(platform, languages, zone, locales)
        if problems:
            flagged += 1
        status = "consistent" if not problems else f"{len(problems)} contradictions"
        print(f"--- {note}")
        print(f"    {platform}, languages={languages}, zone={zone}, "
              f"{len(locales)} ICU locales")
        print(f"    {status}")
        for problem in problems[:6]:
            print(f"      x {problem}")
        if len(problems) > 6:
            print(f"      x ... and {len(problems) - 6} more")
        print()
    print(f"{flagged} of {len(PROFILES)} profiles carry at least one contradiction\n")

    print("font metrics: one sample string, five platforms, five different widths")
    SAMPLE = "mmmwwwiiilll0123"
    for label, width in (("Windows 11 / Segoe UI / 16px", 71.5),
                         ("macOS 14 / Helvetica / 16px", 68.2),
                         ("Linux / DejaVu Sans / 16px", 76.4),
                         ("Android 13 / Roboto / 16px", 70.1),
                         ("iOS 17 / Helvetica / 16px", 68.2)):
        print(f"  {label:32} {width:5.1f}px")

    print("\nCSS generic families, same three names, four different resolutions")
    for family, resolved in (("sans-serif", "Segoe UI, Helvetica, DejaVu Sans, Roboto"),
                             ("serif", "Times New Roman, Times, DejaVu Serif, Noto Serif"),
                             ("monospace", "Consolas, Menlo, DejaVu Sans Mono, Droid Sans Mono")):
        print(f"  {family:12} -> {resolved}")


main()
10 profiles checked against 5 platform sets

--- a German Windows 11 desktop
    Windows 11, languages=['en-US', 'en'], zone=Europe/Berlin, 7 ICU locales
    consistent

--- a Windows profile with locales Windows does not ship
    Windows 11, languages=['en-US', 'en'], zone=Europe/Berlin, 8 ICU locales
    3 contradictions
      x ICU locale 'bn-IN' is not in a stock Windows 11 build
      x ICU locale 'ta-IN' is not in a stock Windows 11 build
      x ICU locale 'sw-KE' is not in a stock Windows 11 build

--- a stock macOS 14 Safari profile
    macOS 14, languages=['en-GB', 'en'], zone=Australia/Sydney, 13 ICU locales
    2 contradictions
      x ICU locale 'id-ID' is not in a stock macOS 14 build
      x ICU locale 'vi-VN' is not in a stock macOS 14 build

--- a Debian container with a full desktop locale set
    Linux (Debian, fontconfig), languages=['en-US', 'en'], zone=America/New_York, 14 ICU locales
    consistent

--- a stock Android 13 Chrome profile on a Tokyo clock
    Android 13, languages=['en-US', 'en'], zone=Asia/Tokyo, 20 ICU locales
    consistent

--- a stock iOS 17 Safari profile
    iOS 17, languages=['ja-JP', 'ja'], zone=Asia/Tokyo, 20 ICU locales
    consistent

--- a container claiming Japanese, which needs fonts Debian does not ship
    Linux (Debian, fontconfig), languages=['ja-JP', 'ja'], zone=Asia/Tokyo, 14 ICU locales
    2 contradictions
      x 'ja-JP' needs a CJK font, Linux (Debian, fontconfig) ships none by default
      x 'ja' needs a CJK font, Linux (Debian, fontconfig) ships none by default

--- a Hebrew iPhone on a Jerusalem clock
    iOS 17, languages=['he-IL', 'he'], zone=Asia/Jerusalem, 20 ICU locales
    consistent

--- a hard-coded Windows list paired with Africa and South Asia locales
    Windows 11, languages=['en-US', 'en'], zone=Asia/Kolkata, 21 ICU locales
    15 contradictions
      x ICU locale 'bn-IN' is not in a stock Windows 11 build
      x ICU locale 'ta-IN' is not in a stock Windows 11 build
      x ICU locale 'te-IN' is not in a stock Windows 11 build
      x ICU locale 'ml-IN' is not in a stock Windows 11 build
      x ICU locale 'gu-IN' is not in a stock Windows 11 build
      x ICU locale 'kn-IN' is not in a stock Windows 11 build
      x ... and 9 more

--- a bare-bones profile with a default clock
    Windows 11, languages=['en-US', 'en'], zone=UTC, 3 ICU locales
    2 contradictions
      x 'en-US' paired with a UTC time zone
      x 'en' paired with a UTC time zone

5 of 10 profiles carry at least one contradiction

font metrics: one sample string, five platforms, five different widths
  Windows 11 / Segoe UI / 16px      71.5px
  macOS 14 / Helvetica / 16px       68.2px
  Linux / DejaVu Sans / 16px        76.4px
  Android 13 / Roboto / 16px        70.1px
  iOS 17 / Helvetica / 16px         68.2px

CSS generic families, same three names, four different resolutions
  sans-serif   -> Segoe UI, Helvetica, DejaVu Sans, Roboto
  serif        -> Times New Roman, Times, DejaVu Serif, Noto Serif
  monospace    -> Consolas, Menlo, DejaVu Sans Mono, Droid Sans Mono

Five of the ten profiles are consistent, and the five that are not fail for four different reasons worth reading separately:

  • Locales the platform does not ship (bn-IN, sw-KE on Windows). A hard-coded list copied from another platform, or invented.
  • A script the platform has no fonts for (Japanese on a bare Debian install, which has no CJK font until one is installed). The language claim and the font list contradict each other.
  • A language and time-zone region that do not go together. Weaker than the other two, since people move, but a profile with no plausible explanation for it is worth a look.
  • A time zone of UTC on a residential-looking profile. Not impossible, but a browser on a real user's machine essentially never reports it, so it reads as a default that was never set.

The stock profiles that pass are the important half. A consistent list is the baseline, and the check only has value because a real device can produce one.

A Checklist for Fonts and Intl

  • Treat the font set as a set. Matching one font's presence proves nothing; the absence of an expected family is as strong as the presence of an impossible one.
  • Remember the metrics. A correct font list with default widths is a different fingerprint from a correct one, and an obviously wrong one.
  • Check the generic families. sans-serif, serif and monospace resolve per platform with no configuration required.
  • Read the locale set, not the current locale. supportedLocalesOf over a long probe list is what identifies the ICU build.
  • Format the same instant twice. Instability in Intl output reads as a new device, which is worse than a coarse profile.
  • Override Intl, not just navigator, or accept that the mismatch is visible in one comparison.
  • Match the ICU version to the browser version if you are impersonating a specific Chrome build.

A Note on the Defensive Side

None of this is an argument against shipping a font or locale probe. It is an argument against shipping one you do not need. If your site has no feature that depends on the visitor's installed fonts or locale data beyond what the Accept-Language header and a CSS @font-face already give you, then the probe is collecting an identifier you have no use for, and the defending against scrapers lesson is where that decision belongs. On a site you operate, the audit starts with the same grep described in Inside Open-Source Fingerprint Collectors: find the components, then justify each one.

Cross-links: Inside Open-Source Fingerprint Collectors, Browser Fingerprinting, Device Profile Consistency, Spoofing Canvas, WebGL & Audio, Hardening Firefox and WebKit Profiles, Geo-Targeted and Localized Scraping, Defending Against Scrapers.