Every Soft Signal Is a Triangle#

So far we have focused on fingerprints the WAF can compute from network packets and JavaScript. But anti-bot systems also triangulate the soft signals that cost them nothing to collect: timezone, language, screen size, and the browser's decline of the whole picture. These signals are weak individually and devastatingly strong in combination, because they must all agree with each other and with your IP geolocation.

The Alignment Triangle#

Every request carries three independent claims about where it comes from:

  1. IP geolocation (from the exit IP's country/city).
  2. Timezone (from Intl.DateTimeFormat().resolvedOptions().timeZone).
  3. Language/Accept-Language (country-specific languages, e.g., ja-JP).

A machine in a Japanese residential IP pool claiming a Pacific/Auckland timezone and a German Accept-Language is detectable in a single glance. Align all three to the IP's country—if the target site is Japanese, prefer Japanese-timezone, Japanese-language, Japanese residential IPs as a bundle.

Locale Consistency#

navigator.languages, navigator.language, Intl.DateTimeFormat, the NumberFormat decimal separator, and date formatting must all agree with the same locale. A profile that says en-GB while formatting dates MM/DD/YYYY is inconsistent. Checklists like Intl group tests in fingerprinting scripts (creepjs-style probes) specifically cross-check these.

Operating System Blending#

Your fingerprint's software stack (OS, browser, fonts, GPU driver) must be a plausible real-world bundle. An "Android" profile with a desktop screen size or a "Windows 11" profile with a macOS GPU driver string is nonsense. Choose a target device family (Windows 11 + Chrome 124 + a common consumer GPU + standard TTF fonts) and reuse that constellation across sessions.

When Signals Conflict#

The most common real-world failure is not a wrong value, but an inconsistent pair. Examples that get sessions flagged instantly:

  • Accept-Language: ja-JP while navigator.language reports en-US.
  • An IP in Germany with a Pacific/Auckland timezone, or a de-DE language.
  • A viewport of 1366×768 on a device claiming to be an iPhone.
  • sec-ch-ua-platform: "Windows" while the UA says macOS.

WAFs rarely rely on one of these alone; they flag the contradiction. When hardening a profile, audit it as a whole by dumping every client-visible signal and checking that no pair disagrees with the IP's origin.

The Alignment Checklist#

A profile is production-ready when all of these agree: exit IP country, timezone, navigator.language and navigator.languages, Accept-Language header, date and number formatting, screen/viewport size, sec-ch-ua platform, GPU renderer string, and installed font set. Test the bundle against the target's locale requirements before running any volume.

The Profile Lifecycle#

A browser profile is a persistent identity: fixed fingerprint, fixed geolocation, fixed IP pool region, even a personality (average visit duration, typical referrers). Create it once, let it "age" by browsing bland sites occasionally, and never mutate it mid-scrape. The profile is your camouflage; mutating it mid-flight is how bots get caught. Anti-detect browsers manage this natively; with raw Playwright you must store and reload context state (cookies, localStorage, origin data) between sessions.

Reusing the Same Profile Across Many Jobs#

Environments that reuse one fingerprint for all traffic are a huge red flag to WAFs—thousands of "different people" sharing identical fingerprints is a textbook correlation. Distinguish identities in your fleet: organize profiles into independent device families that bear no shared fingerprint, so no single pattern repeats at scale.

Build the Profile as a Bundle, Not a Bag of Settings#

The unit of stealth is a device family: a coherent description of one plausible physical machine. You assemble it once, freeze it, and every request in the fleet presents the same bundle. Assembling it field by field as you discover requirements produces exactly the contradictions described above, because each field is chosen in isolation and nothing checks the combination.

A workable assembly order, which is the reverse of the order the signals are usually discovered in:

  1. Pick the platform and the browser build together. Chrome on Windows 11 x64 is a family; Chrome 126 on Linux with a Windows GPU string is not a family.
  2. Take the screen, viewport and DPR from a real resolution class. A 1920x1080 DPR 1.0 desktop, a 2560x1440 DPR 1.5 laptop, a 1280x800 DPR 2.0 MacBook — pick the class, then the viewport must fit inside the screen, and the ratio of viewport to screen must look like a real window.
  3. Take the GPU from that platform's plausible population. On Windows the renderer string contains Direct3D11 and an Intel, NVIDIA or AMD device; on macOS it contains Metal and an Apple or AMD GPU. Take the codec set next and check it against the same platform, because the H.264 and AV1 profiles a device supports depend on the hardware.
  4. Take fonts from the platform default set plus a plausible install history. The default set is the platform's own UI font; the extra fonts are what a person's machine accumulates.
  5. Take locale as a bundle of eight or nine fields — language, languages array, accept-language, timezone, date and number formatting, currency format, first-day-of-week — all resolved from one locale.
  6. Take the scalar capability values from the platform's real distribution. hardwareConcurrency, deviceMemory, maxTouchPoints and the audio sample rate all have legal value sets, and the legal sets are platform-specific.

The order matters because later steps constrain earlier ones. Knowing the platform tells you which DPR values are plausible; knowing the DPR tells you which screen class; knowing the GPU tells you which codecs; knowing the codec set tells you what the browser build can claim.

The Contradiction Taxonomy#

Every cross-field check you can automate falls into one of six families. This is the list to implement.

Family Example check Detection cost
Internal contradiction viewport wider than screen; navigator.language not first in navigator.languages trivial, a JS comparison
Platform incoherence Direct3D11 renderer on an Android UA; macOS-only fonts on Windows trivial, a lookup
Magnitude impossibility deviceMemory: 3; hardwareConcurrency: 64; audio rate 44100 and 48000 both claimed trivial, a legal-value set
Version contradiction AV1 support with a Chrome build older than 90 one table lookup
Geographic incoherence Pacific/Auckland with de-DE; timezone with no DST where the language requires it locale table
Cardinality collision a profile that appears identically on thousands of sessions correlation, not comparison

The first four are the ones to build first: they cost nothing, they have no false-positive risk if your legal-value tables are right, and they catch the large majority of real mistakes. Cross-session cardinality is the one you cannot fix by editing a profile, only by generating enough distinct ones — see for the correlation mechanics.

A working auditor over three profiles, one coherent and two broken in different ways:

import re

PROFILES = {
    "coherent_win11": {
        "ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
              "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
        "ua_platform": "Windows",
        "ua_platform_version": "15.0.0",
        "viewport": [1920, 945],
        "screen": [1920, 1080],
        "dpr": 1.0,
        "gpu_vendor": "Google Inc. (Intel)",
        "gpu_renderer": "ANGLE (Intel, Intel(R) UHD Graphics 770 (0x4680) "
                        "Direct3D11 vs_5_0 ps_5_0, D3D11)",
        "timezone": "Europe/Berlin",
        "language": "de-DE",
        "languages": ["de-DE", "de", "en-US", "en"],
        "accept_language": "de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7",
        "device_memory": 8,
        "hardware_concurrency": 12,
        "audio_sample_rate": 48000,
        "av1": True,
        "fonts": ["Segoe UI", "Arial", "Consolas", "Georgia", "Cambria Math"],
    },
    "near_miss": {
        "ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
              "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
        "ua_platform": "Windows",
        "ua_platform_version": "15.0.0",
        "viewport": [1536, 815],
        "screen": [1920, 1080],
        "dpr": 1.5,
        "gpu_vendor": "Google Inc. (Intel)",
        "gpu_renderer": "ANGLE (Apple, Apple M2, OpenGL 4.1)",
        "timezone": "Europe/Berlin",
        "language": "de-DE",
        "languages": ["de-DE", "de", "en-US", "en"],
        "accept_language": "de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7",
        "device_memory": 8,
        "hardware_concurrency": 64,
        "audio_sample_rate": 48000,
        "av1": True,
        "fonts": ["Helvetica Neue", "Menlo", "Segoe UI"],
    },
    "broken_android": {
        "ua": "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 "
              "(KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36",
        "ua_platform": "Android",
        "ua_platform_version": "13.0.0",
        "viewport": [1366, 768],
        "screen": [1366, 768],
        "dpr": 1.0,
        "gpu_vendor": "Google Inc. (Google)",
        "gpu_renderer": "ANGLE (Intel, Intel(R) UHD Graphics 770 (0x4680) "
                        "Direct3D11 vs_5_0 ps_5_0, D3D11)",
        "timezone": "Pacific/Auckland",
        "language": "en-US",
        "languages": ["en-US", "en"],
        "accept_language": "en-US,en;q=0.5",
        "device_memory": 8,
        "hardware_concurrency": 8,
        "audio_sample_rate": 44100,
        "av1": False,
        "fonts": ["Roboto", "Helvetica Neue", "Menlo", "Comic Sans MS"],
    },
}

TZ_COUNTRY = {
    "en-US": ("America/New_York", "America/Los_Angeles", "America/Chicago"),
    "de-DE": ("Europe/Berlin",),
    "ja-JP": ("Asia/Tokyo",),
    "en-GB": ("Europe/London",),
    "pt-BR": ("America/Sao_Paulo",),
}

DEVICE_MEMORY_VALUES = (0.25, 0.5, 1, 2, 4, 8)
AUDIO_RATES = (44100, 48000)
FONTS_BY_PLATFORM = {
    "Windows": {"Segoe UI", "Consolas", "Cambria Math", "Arial"},
    "macOS": {"Helvetica Neue", "Menlo", "Palatino"},
    "Android": {"Roboto", "Noto Sans CJK JP"},
}


def ua_version(ua):
    m = re.search(r"Chrome/(\d+)\.", ua)
    return int(m.group(1)) if m else None


def ua_family(ua):
    if "Windows NT" in ua:
        return "Windows"
    if "Macintosh" in ua or "Mac OS X" in ua:
        return "macOS"
    if "Android" in ua:
        return "Android"
    return "unknown"


RULES = [
    ("platform vs ua", lambda p: ua_family(p["ua"]) != p["ua_platform"]),
    ("ua_major vs av1 support",
     lambda p: ua_version(p["ua"]) is not None and ua_version(p["ua"]) < 90 and p["av1"]),
    ("viewport exceeds screen",
     lambda p: p["viewport"][0] > p["screen"][0] or p["viewport"][1] > p["screen"][1]),
    ("mobile family at desktop dpr",
     lambda p: ua_family(p["ua"]) == "Android" and p["dpr"] < 2.0),
    ("dpr disagrees with screen class",
     lambda p: p["dpr"] == 1.5 and p["screen"][0] != 2560),
    ("renderer driver vs platform",
     lambda p: "Direct3D" in p["gpu_renderer"] and p["ua_platform"] != "Windows"),
    ("renderer vendor vs ua family",
     lambda p: "Apple" in p["gpu_renderer"] and p["ua_platform"] == "Android"),
    ("timezone outside language country",
     lambda p: p["timezone"] not in TZ_COUNTRY.get(p["language"], ())),
    ("navigator.language not first in list",
     lambda p: p["languages"][0] != p["language"]),
    ("accept-language misses navigator.language",
     lambda p: not p["accept_language"].startswith(p["language"])),
    ("accept-language q-values unsorted",
     lambda p: [float(x.split("q=")[1]) for x in p["accept_language"].split(",")[1:]]
     != sorted([float(x.split("q=")[1]) for x in p["accept_language"].split(",")[1:]],
               reverse=True)),
    ("deviceMemory magnitude impossible",
     lambda p: p["device_memory"] not in DEVICE_MEMORY_VALUES),
    ("hardwareConcurrency outside real range",
     lambda p: not 2 <= p["hardware_concurrency"] <= 32),
    ("audio rate not a real output rate",
     lambda p: p["audio_sample_rate"] not in AUDIO_RATES),
    ("font set crosses platform",
     lambda p: bool(FONTS_BY_PLATFORM["macOS"] & set(p["fonts"])
                    - FONTS_BY_PLATFORM[p["ua_platform"]])
     or bool({"Comic Sans MS"} & set(p["fonts"]))),
]

print("Device-profile coherence audit: %d cross-field rules" % len(RULES))
print("")
for name in sorted(PROFILES):
    p = PROFILES[name]
    fired = [label for label, test in RULES if test(p)]
    print("%s  (%d contradictions)" % (name, len(fired)))
    for label in fired:
        print("    - " + label)
    if not fired:
        print("    - none")
    print("")

counts = {name: sum(1 for _, t in RULES if t(PROFILES[name])) for name in PROFILES}
print("verdict table")
print("%-20s %8s %10s %14s" % ("profile", "contradictions", "verdict", "needs edits"))
for name in sorted(counts):
    c = counts[name]
    print("%-20s %8d %10s %14s" % (
        name, c, "ship" if c == 0 else "reject", 0 if c == 0 else c))
Device-profile coherence audit: 15 cross-field rules

broken_android  (4 contradictions)
    - mobile family at desktop dpr
    - renderer driver vs platform
    - timezone outside language country
    - font set crosses platform

coherent_win11  (0 contradictions)
    - none

near_miss  (3 contradictions)
    - dpr disagrees with screen class
    - hardwareConcurrency outside real range
    - font set crosses platform

verdict table
profile              contradictions    verdict    needs edits
broken_android              4     reject              4
coherent_win11              0       ship              0
near_miss                   3     reject              3

The near_miss profile is the instructive one. Every individual value is realistic: a 1536x815 viewport on a 1920x1080 screen happens, DPR 1.5 happens, hardwareConcurrency of 12 is common. What is wrong is the combination — DPR 1.5 with a 1920-wide screen is a 2560-class display, an Apple M2 renderer on a Windows UA is a different platform, and a font set split between macOS and Windows families means the profile was assembled from two sources. No single field would ever have been caught.

Locale Is Nine Fields, Not One#

Locale coherence is the highest-value bundle because it is cheap to get right and immediately visible when wrong. The fields that must agree:

  • navigator.language — the primary locale tag
  • navigator.languages — the ordered array, with the primary first and decreasing specificity after it
  • the Accept-Language header, whose q-values must be in descending order and whose first entry must match the primary
  • Intl.DateTimeFormat().resolvedOptions().timeZone
  • the ICU locale behind number formatting — decimal separator, group separator, and the percent and currency formats
  • the date format order: MM/DD/YYYY in en-US, DD/MM/YYYY in en-GB, YYYY-MM-DD in ja-JP
  • Intl.DateTimeFormat().resolvedOptions().locale — a third separate copy that frameworks sometimes set and then forget
  • weekInfo / first-day-of-week, which differs by region and not by language
  • the ICU build version, which is itself a version fingerprint (see )

The trap is that these come from different subsystems with different override mechanisms, and it is easy to set three of them and leave the rest at the browser's install-locale default. That is how you get en-GB formatting dates as MM/DD/YYYY: the language override applied, the region override did not, because they are configured through different channels.

One field deserves special attention: timezone. It is not only a locale question, it is also a geographic question, and it is the field most often left at the host default. In a container that is UTC or Etc/UTC; Etc/UTC in particular is rare in the wild and worth never emitting. The timezone must be consistent with the locale and plausible for the exit IP, and where the IP is residential you should take it from the IP's city rather than from the country, because a country-level timezone guess across a large country is a one-in-four chance of being wrong per user and the defender only needs to aggregate across sessions to see it.

Fonts Need Three Consistent Views#

A font claim is not one field. Defenders read it three ways and the three must agree.

The enumeration — the list of families the page reports as available, whether by width measurement over a candidate list or by document.fonts.check — should be the platform default set plus a plausible personal install.

The metrics — the measured advance width of a string in a given family at a given size — must match what that font's real build produces. Same family name, different build, different width: the hinting and version differences between two releases of a common font are measurable.

The derived views — offsetWidth of a span containing test strings, measureText values, and the width of a rendered string in the default UI font — must be mutually consistent. A profile that overrides the enumerated list but not the measured widths is caught by comparing them.

The coherence requirement is that the extra fonts look like something a person on that platform would have installed, not like a maximal list. A Windows machine with 200 fonts is a font pack; a Windows machine with 5 fonts including Comic Sans MS is a person who installed Comic Sans MS, which is rarer and therefore more identifying. Deep-dive material on all three views is in .

What Automated Consistency Checks Catch#

You cannot verify a profile by looking at it. The auditor has to run the same probes the defender runs and compare the results against the claimed device class. The pipeline is:

  • Collect, not assert. Dump every client-visible signal from a real browser you have configured, into a file you can diff. Do not maintain the expected values in your head; maintain them as an artefact. The collectors in are the reference implementation of this step.
  • Diff against the profile, in both directions. For each declared field, is the observed value equal? And for each observed value, is it declared? The second direction catches the un-overrideable fields — the ones your configuration cannot reach and which are silently reporting the host's real values. That is how a headless signature survives an otherwise perfect override.
  • Run the legal-value checks. Magnitude and version tables from this section, as code, as a test.
  • Run the coherence checks from the auditor above against the observed dump, not against your configuration object.
  • Test the un-overrideable surface explicitly. Function.prototype.toString over every patched function, the enumerability and toString of every navigator property, the error stack shape, and the CDP-attachment artefacts. Those are covered in and the self-consistency bar is in .

The one that catches people is the second direction. A profile can be internally perfect, agree with every configured value, and still be describing a machine whose real canvas hash, real font widths and real codec support were never reachable by the override mechanism. The observed dump is the only thing that shows this.

What a Real Auditor Should Reject#

A useful auditor does not just count contradictions; it rejects the class of profile that cannot exist. Three checks catch the failures that per-field rules miss.

The class-coherence test. Ask which real devices could produce this tuple. If the answer is none, the profile is fictional. Concretely: build the joint distribution of (UA family, screen size, DPR, GPU vendor, codec set) from a corpus of real captures, and require the profile to land inside a populated cell. This catches the plausible-but-impossible bundle that no individual rule knows about.

The entropy test. Compute the entropy of each field across your own fleet. A field with one distinct value across 10,000 profiles carries zero identifying information at fleet scale and is itself a signal — the correlation mechanics are in , and the cardinality problem itself is in . A profile whose deviceMemory is always 8 is a fleet with one device.

The version-recency test. Real devices are not all on the newest build. If every profile in the fleet claims the current Chrome release, the fleet is a deployment artefact, not a user base. A realistic fleet spans the last several releases in the proportions real traffic does. keeping-current is the lesson on the release-cadence side of this, and the version-contradiction failure mode is spelled out in .

Regression Tests for the Profile Bundle#

The auditor above is a one-off audit until it is a test suite, and the difference determines whether coherence survives the next browser release. Four test layers, cheapest first.

Legal-value tests. Every field against its platform's real value set: screen resolutions for the claimed class, DPR values, deviceMemory, hardwareConcurrency ranges, audio sample rates, maxTouchPoints, timezone identifiers from the IANA database. Pure data, no browser required, and it catches the majority of edits. This layer is a table lookup per field and belongs in the same commit as the profile definition.

Cross-field tests. The coherence rules, run over the declared profile. Also pure data, but the rule set needs extending every time a field is added — which is the actual cost of this layer, and the reason to keep the rules in a data file rather than scattered through the code.

Observation tests. Run the probe collector against a real browser configured with the profile and diff the result against the declaration, in both directions. This is the layer that catches un-overrideable fields, and it is the only layer that can fail for reasons unrelated to your configuration. It needs a browser, so it belongs on a slower schedule.

Population tests. Run the bundle against a corpus of real device records and require it to land in a populated cell. Needs the corpus, needs the class-coherence data from the audit section, and is the most expensive layer. It is also the only one that catches a bundle which is internally perfect and describes a machine that does not exist.

Wire them so that a failure names the layer: a legal-value failure says "this field is impossible", a cross-field failure says "these two fields disagree", an observation failure says "the browser is reporting something your profile does not claim", and a population failure says "this bundle is fictional". Those four messages have four different owners and four different fixes, and a single generic "profile failed coherence" message forces everyone to re-derive which one happened.

The cadence matters as much as the layers. Layers one and two run on every profile change, which is per-commit and costs seconds. Layer three runs nightly against the current browser build, because it is the layer that breaks when the browser changes. Layer four runs weekly or on demand, because its inputs — browser builds, driver versions — move monthly at most. Running all four on every commit is how this work gets abandoned; the trick is that the fast layers are also the ones that catch deliberate mistakes, which is most of what they are for.