The Unseen Tracker#

In the early days of the web, tracking users relied entirely on HTTP Cookies. Today, privacy extensions and incognito modes routinely destroy cookies. In response, ad-networks and anti-bot companies developed Browser Fingerprinting.

A fingerprint is a highly unique, persistent identifier generated by collecting dozens of hardware and software configurations from your browser using JavaScript. Even if you change your IP and clear your cookies, your fingerprint remains identical.

Primary Fingerprinting Vectors#

1. Canvas Fingerprinting The WAF injects JavaScript that commands your browser to draw a hidden, complex 2D graphic (containing text, colors, and gradients) onto an HTML <canvas> element. Because different Operating Systems, GPUs, and graphics drivers render anti-aliasing and sub-pixel hinting slightly differently, the resulting image is unique at the pixel level. The script hashes the image data to generate your Canvas ID.

2. WebGL Fingerprinting Similar to Canvas, but targets the 3D graphics API. The script asks WebGL to render a 3D geometry and extracts deep hardware metadata, explicitly revealing your exact GPU vendor (e.g., Apple M2 Max or NVIDIA RTX 4090) and driver version.

3. AudioContext Fingerprinting The script generates a low-frequency audio sine wave using the Web Audio API, processes it through digital filters, and measures the mathematical output. Different CPU architectures and audio soundcards produce microscopically different audio waves.

4. Font & Screen Fingerprinting - The script measures the exact pixel width of a specific string of text rendered in 50 different fonts to deduce exactly which fonts are installed on your OS. - It records your screen resolution, color depth, and available window real estate.

The Paradox of Spoofing#

Your first instinct to bypass this is to simply block the JavaScript from executing. Do not do this. WAFs treat a lack of a fingerprint as an absolute guarantee that you are a bot.

Your second instinct is to randomize your fingerprint on every request. This is also dangerous. If your fingerprint claims you are running a 4K monitor, but your User-Agent says you are an iPhone 12, the WAF detects the mismatch and bans you. A fingerprint is only useful as camouflage if every component is internally consistent.

Entropy Is the Enemy of Accuracy#

The power of fingerprinting comes from the sheer number of independent signals. Alone, your screen size identifies hundreds of thousands of other people. Multiply it by your canvas hash, your WebGL renderer string, your installed fonts, your audio output, your timezone, and your language list, and the joint probability becomes brutally unique. Real-world browser diversity means almost no two humans share all values simultaneously. This is why even one small inconsistency (an unusual font combination, a renderer string that never appears on any real device) can single you out.

Consistency Across Visits#

Anti-bot systems do not just fingerprint you once. They store your fingerprint server-side and correlate it across every subsequent session. A fingerprint that changes wildly between two visits is treated as a hijacked session or a bot swapping identity. Your spoofed identity should therefore be stable for the lifetime of a profile, not randomized per request. This is exactly how commercial anti-detect browsers model it: each long-lived browser profile carries one fixed fingerprint.

The Role of Noise Injection#

Successful evasion requires using specialized stealth browsers (like GoLogin or AdsPower) that carefully inject noise into the Canvas and Audio APIs at the C++ level. The trick is careful noise: a tiny, deterministic perturbation applied consistently, ensuring the spoofed fingerprint is perfectly mathematically consistent across all parameters, while remaining distinguishable from every real hardware profile in the WAF's database.

A Fingerprint Is an Entropy Budget#

The word "fingerprint" suggests a number, and it is worth discarding that framing. A fingerprint is a vector, and the vector has a size. You can measure it directly: count the distinct values each surface takes across a large sample of real clients, take the base-2 logarithm, and you have that surface's contribution in bits. Sum the surfaces and you have the separation budget your stack actually delivers.

This reframing matters because it turns a vague goal into a budget you can spend. A surface with 2.3 bits cannot identify anybody; it partitions your fleet into five groups. A surface with 21.9 bits can single out a client out of four million. When you are deciding what to spoof, the question is not "is this surface detectable" but "what does this surface buy the vendor, and what does it cost me to lie about it".

# A fingerprint is an entropy budget, and most of the budget is platform.
# Everything below is arithmetic on observed distinct-value counts, which
# is the only honest way to reason about "is this signal worth spoofing".

import math

# surface, distinct values observed across a crawl, class
SURFACES = [
    ("user_agent_string", 412, "derived"),
    ("platform", 5, "derived"),
    ("navigator.language", 34, "stable"),
    ("navigator.languages[]", 29, "stable"),
    ("intl.timezone", 418, "stable"),
    ("screen.width x height", 964, "stable"),
    ("availWidth x availHeight", 611, "stable"),
    ("devicePixelRatio", 9, "stable"),
    ("colorDepth x pixelDepth", 6, "stable"),
    ("hardwareConcurrency", 12, "stable"),
    ("deviceMemory", 8, "stable"),
    ("maxTouchPoints", 5, "stable"),
    ("plugins[]", 24, "stable"),
    ("fonts enumeration", 156, "stable"),
    ("webgl vendor", 41, "stable"),
    ("webgl renderer", 233, "stable"),
    ("webgl parameter hash", 88400, "stable"),
    ("audio context hash", 41700, "stable"),
    ("canvas image hash", 1284000, "stable"),
    ("client rects pattern", 3900000, "stable"),
    ("document.referrer length", 240, "volatile"),
    ("screen.availLeft x availTop", 300, "volatile"),
    ("timing granularity (us)", 1000, "volatile"),
]

DERIVED_FROM_PLATFORM = {"user_agent_string", "platform"}
FLEET = 10000


def bits(n):
    return math.log2(n)


def budget(classes):
    return sum(bits(distinct) for name, distinct, klass in SURFACES
               if klass in classes)


def collision(distinct, n=FLEET):
    # birthday bound: p(at least one colliding pair in a fleet of n)
    return 1.0 - math.exp(-pairs(distinct, n))


def pairs(distinct, n=FLEET):
    return n * (n - 1) / 2.0 / float(distinct)


def platform_conditional():
    # Conditional on platform == Win32, the derived surfaces collapse to one
    # value and the remaining stable set is what is left to separate clients.
    left = sum(bits(distinct) for name, distinct, klass in SURFACES
               if klass == "stable" and name not in DERIVED_FROM_PLATFORM)
    return left


def main():
    print("per-surface entropy, observed distinct values across a crawl")
    print(f"{'surface':<30} {'distinct':>10} {'bits':>6}  class")
    for name, distinct, klass in SURFACES:
        print(f"{name:<30} {distinct:>10,} {bits(distinct):>6.2f}  {klass}")
    print()

    print("the budget, four ways")
    everything = budget({"stable", "volatile", "derived"})
    stable = budget({"stable", "derived"})
    cond = platform_conditional()
    volatile = budget({"volatile"})
    print(f"  all surfaces joined                     {everything:6.1f} bits")
    print(f"  stable surfaces, derived included       {stable:6.1f} bits")
    print(f"  stable, platform-derived surfaces gone  {cond:6.1f} bits")
    print(f"  volatile surfaces only                  {volatile:6.1f} bits")
    print(f"  share of the budget that is volatile    {100 * volatile / everything:5.1f}%")
    print()

    print("how a fleet collides: expected colliding pairs, then p(collision)")
    print(f"{'surface':<30} {'distinct':>10} {'pairs @10k':>12} {'p @40':>8} {'p @10k':>8}")
    for name, distinct, klass in sorted(
            [(s[0], s[1], s[2]) for s in SURFACES if s[2] == "stable"],
            key=lambda s: s[1], reverse=True)[:6]:
        print(f"{name:<30} {distinct:>10,} "
              f"{pairs(distinct, 10000):>12.3e} "
              f"{collision(distinct, 40):>8.3f} "
              f"{collision(distinct, 10000):>8.3f}")
    print()

    print("the surfaces worth spoofing: top 5 stable bits per client")
    ranked = sorted([(name, distinct, bits(distinct)) for name, distinct, klass
                     in SURFACES if klass == "stable"
                     and name not in DERIVED_FROM_PLATFORM],
                    key=lambda r: -r[2])
    for name, distinct, b in ranked[:5]:
        print(f"  {name:<30} {distinct:>10,} values, {b:6.2f} bits")
    print(f"  these five alone carry {sum(r[2] for r in ranked[:5]):.1f} of the "
          f"{platform_conditional():.1f} bits left once platform is fixed")
    print()

    print("conditional on platform == Win32, the derived surfaces collapse")
    print(f"  stable budget before conditioning      {stable:6.1f} bits")
    print(f"  stable budget after conditioning       {platform_conditional():6.1f} bits")
    print(f"  collapsed by                           {stable - platform_conditional():6.1f} bits")
    print("  so a Win32 profile is already separated by platform alone;")
    print("  the vendor's remaining job is the long tail of the stable set.")


main()
per-surface entropy, observed distinct values across a crawl
surface                          distinct   bits  class
user_agent_string                     412   8.69  derived
platform                                5   2.32  derived
navigator.language                     34   5.09  stable
navigator.languages[]                  29   4.86  stable
intl.timezone                         418   8.71  stable
screen.width x height                 964   9.91  stable
availWidth x availHeight              611   9.26  stable
devicePixelRatio                        9   3.17  stable
colorDepth x pixelDepth                 6   2.58  stable
hardwareConcurrency                    12   3.58  stable
deviceMemory                            8   3.00  stable
maxTouchPoints                          5   2.32  stable
plugins[]                              24   4.58  stable
fonts enumeration                     156   7.29  stable
webgl vendor                           41   5.36  stable
webgl renderer                        233   7.86  stable
webgl parameter hash               88,400  16.43  stable
audio context hash                 41,700  15.35  stable
canvas image hash               1,284,000  20.29  stable
client rects pattern            3,900,000  21.90  stable
document.referrer length              240   7.91  volatile
screen.availLeft x availTop           300   8.23  volatile
timing granularity (us)             1,000   9.97  volatile

the budget, four ways
  all surfaces joined                      188.7 bits
  stable surfaces, derived included        162.5 bits
  stable, platform-derived surfaces gone   151.5 bits
  volatile surfaces only                    26.1 bits
  share of the budget that is volatile     13.8%

how a fleet collides: expected colliding pairs, then p(collision)
surface                          distinct   pairs @10k    p @40   p @10k
client rects pattern            3,900,000    1.282e+01    0.000    1.000
canvas image hash               1,284,000    3.894e+01    0.001    1.000
webgl parameter hash               88,400    5.656e+02    0.009    1.000
audio context hash                 41,700    1.199e+03    0.019    1.000
screen.width x height                 964    5.186e+04    0.555    1.000
availWidth x availHeight              611    8.182e+04    0.721    1.000

the surfaces worth spoofing: top 5 stable bits per client
  client rects pattern            3,900,000 values,  21.90 bits
  canvas image hash               1,284,000 values,  20.29 bits
  webgl parameter hash               88,400 values,  16.43 bits
  audio context hash                 41,700 values,  15.35 bits
  screen.width x height                 964 values,   9.91 bits
  these five alone carry 83.9 of the 151.5 bits left once platform is fixed

conditional on platform == Win32, the derived surfaces collapse
  stable budget before conditioning       162.5 bits
  stable budget after conditioning        151.5 bits
  collapsed by                             11.0 bits
  so a Win32 profile is already separated by platform alone;
  the vendor's remaining job is the long tail of the stable set.

Stable, Volatile, and Derived#

The class column is the part that should drive your architecture, and the three classes behave differently under rotation.

Stable surfaces describe the machine: screen geometry, GPU parameters, core count, installed fonts. They do not change for the life of a profile, which is exactly what makes them useful for linking and exactly what makes them expensive to fake. If a vendor sees the same canvas hash on fifty requests across three days, those requests are one device, and that is a stronger statement than any header you could forge.

Volatile surfaces change within a session and are therefore nearly useless for linking: referrer length, window position, timing granularity. Together they account for 26.1 bits, or 13.8% of the budget. Ignoring them costs you almost nothing, and chasing them costs a lot of patch surface. This is the first place to save engineering effort.

Derived surfaces are not independent evidence at all. navigator.platform and the user agent string are functions of the operating system, and every other value in the vector is conditioned on them. Treat them as a consistency requirement rather than as signals to spoof, which is the subject of a later section.

The Birthday Bound Decides What a Signal Is Worth#

The p @40 and p @10k columns are the part of this output that changes how people build fleets. A signal's value is not its entropy in isolation; it is its entropy relative to how many clients are being compared.

At forty clients, screen.width x height already produces a colliding pair 55.5% of the time, and availWidth x availHeight 72.1%. Two Windows laptops with a 1920x1080 screen report those values, and nothing about that pair is suspicious. By ten thousand clients every surface in the table collides with probability 1.000, including the 21.9-bit client-rects pattern, which generates 12.82 expected colliding pairs.

Two operational consequences follow. First, high-entropy surfaces are worthless as blockers: they flag honest users and they flag you. Second, and more usefully, they are excellent as corroboration: a canvas hash you never selected, on a machine you have never seen, agreeing with the client-rects pattern on the same request is far stronger evidence than either alone. Real vendors weight by agreement between independent surfaces, which is why a coherently spoofed fingerprint that is subtly inconsistent is worse than a plain unmodified one.

Where to Spend the Budget#

The top-five list is the answer to "what should I actually override". client rects, canvas, WebGL parameters, audio context and screen geometry carry 83.9 bits between them, more than half of the 151.5 bits that remain once platform is fixed.

The cost ranking is the reverse of the entropy ranking, and that is the tension. Screen geometry is one addInitScript line per context option, is impossible to get subtly wrong, and yields 9.91 bits. Client-rects requires intercepting layout at a level most patch surfaces do not reach, is easy to get subtly wrong, and yields 21.9 bits. A team that spends a month on the highest-entropy surface and ships the cheap one properly ends up worse off than a team that did the reverse.

The order that works: pin the context options first because they are cheap and they constrain everything else, then WebGL vendor and renderer strings because they are strings, then canvas and audio because they are hashes you can select from a prepared pool. Leave client-rects alone unless you are already patching the browser. See open-source fingerprint collectors for what each surface actually costs to read, and canvas and WebGL spoofing for the pool-selection mechanics.

Surfaces Are Not Independent Numbers#

The failure mode of budget-driven spoofing is treating each surface as a slot to fill. They are not independent; they are outputs of one rendering stack, and they agree with each other whether or not you intervened.

navigator.userAgentData.getHighEntropyValues() returns platformVersion, architecture, bitness, model, uaFullVersion and fullVersionList, all of which must agree with the UA string, with navigator.platform, and with the HTTP Sec-CH-UA headers. The GPU's unmasked renderer string names a driver version that implies a specific ANGLE backend, which implies a specific shader precision set, which is what the WebGL parameter hash measures. devicePixelRatio combined with screen.width determines the physical CSS viewport, and the client rects pattern is a function of font metrics that you have not touched, so overriding the screen but not the fonts leaves a self-inconsistent profile.

The practical rule is that you set one root decision per profile, platform plus browser version plus GPU class, and you let every surface derive from it. The output above quantifies what you get for free: conditioning on platform collapses 11.0 bits, because the derived surfaces stop contributing independent evidence. Cross-validation is the deep treatment of which pairs the vendors actually check, and it is worth reading before you decide which overrides to add.

What the Vendor Sees Is Your Rarity, Not Your Value#

There is a detection strategy that never looks at any single surface and never inspects your headers, and it is the one that catches most stealth forks. Instead of asking whether a value is plausible, it asks how common that value is among the clients that have already been cleared. A client is scored by the rarity of its vector, not by the contents of it.

The consequence is uncomfortable and specific: a perfect replica of a MacBook Pro that five hundred other cleared clients also perfectly replicate is less suspicious than an honest mid-range Windows machine nobody else is using. This is why a fleet of identical profiles is a liability, and why the jitter that packaged stealth forks apply to screen and locale is aimed at the right problem. It is aimed at the right problem for the wrong reason, though; jitter that is applied uniformly and independently per property produces vectors no real machine has, which is the subject of population-scale detection.

The operational form of the defence is a profile pool with real per-device variance in the correlated fields, reused across sessions only as long as a real device would be, and rotated on a schedule you can defend as hardware replacement rather than as challenge response. Behavioral biometrics covers the rotation half; the entropy half is what the numbers above are for.

When You Cannot Spoof a Surface, Hide the Read#

There is a legitimate answer for surfaces you cannot afford to fake, and it is not a better fake. It is making the read return nothing.

If the canvas surface costs 20.29 bits and you cannot produce a coherent value for it, then a browser that reports a null or unsupported result for canvas readback removes the surface from the vendor's budget entirely, along with everything it would have corroborated. The risk is obvious and worth stating: a fleet where canvas is uniformly absent is a fleet with a detectable signature, so this is a decision about which population you want to look like, and it should be made per profile rather than per fleet.

This is the reasoning that makes patching Chromium the more honest option than script injection for long-lived identities. A patch lives inside the rendering stack, so the canvas hash, the client-rects pattern, the WebGL parameter hash and the font-derived measurements all continue to agree with each other, because they are all still produced by the same code path as each other. Injected scripts break that agreement by construction, because a different code path is producing one surface and the original is producing all the others. Where a patch is not possible, hardening through CDP buys back coherence for the surfaces you do control.