Rendering Lies With Mathematical Honesty#

Canvas, WebGL and AudioContext produce the strongest hardware-bound components of a fingerprint. They are also the hardest to fake, which is exactly why they matter. A naive scraper that "passes" every network layer will still produce a real GPU renderer string that heads the WAF's detectors. Faking these components requires handling them at three nested levels.

The Three Levels of GPU/Canvas Lies#

Level 1: WebGL renderer strings. The WebGL context exposes WEBGL_debug_renderer_info which gives VENDOR and RENDERER strings like Google Inc. / ANGLE (NVIDIA, NVIDIA GeForce RTX 3070 Direct3D ...). A spoofed profile should override these to match the target device family. But strings alone are weak—anyone can set them, so WAFs correlate them with the rest of the browser.

Level 2: Consistent numeric noise. Canvas fingerprints are pixel hashes. To fake one, you must render the exact same challenge image the WAF draws, then inject a pixel-level perturbation that is mathematically stable (deterministic for a given profile) so your "fingerprint" is reproducible visit after visit. Anti-detect browsers implement this natively in C++ by patching the Skia rasterizer: every canvas and WebGL draw returns identical bits for a given profile, differing from the real hardware only by a tiny, consistent delta that survives hashing.

Level 3: Cross-test coherence. The killer mistake is a spoof that passes only the direct canvas probe. WAFs re-shuffle the challenge (different text, different colors, different geometry) each session, and they combine canvas with WebGL and Audio results. A fix that hardcodes one canvas response fails the moment the challenge changes. The robust approach is to apply a deterministic noise function that perturbs any drawing operation the same way, so all variants hash consistently.

AudioContext Spoofing#

The audio fingerprint plays a short sine wave through the backend and hashes the output. Real audio backends produce tiny device-specific differences. Spoofing audio requires patching the Web Audio backend, which is why it is almost exclusively done at the browser-fork level (anti-detect browsers) rather than by JavaScript overrides. Naive attempts to override AudioContext in JS fail because the fingerprint is computed from the native backend output, not from a JS-visible value.

The Exponential Cost of Getting It Right#

Between canvas variants, WebGL renderer metadata, RGBA noise, audio output, and font width measurements, a WAF can generate a nearly infinite family of probes. Deterministic noise injection at the raster/runtime level is the only maintainable defense: it answers every variation consistently, so the WAF's composite hash stays stable while remaining unlike any real device. This is why serious scale operations buy anti-detect browser forks instead of layering more JS patches.

Testing Your Spoof#

Verify canvas/WebGL consistency the way detectors do: collect the same probe twice in one session, then again in a fresh session of the same profile. A correct spoof returns the same hash within a profile but a hash distinct from any known real-hardware signature. A broken spoof changes hash every call (caught) or returns the exact software-renderer hash (also caught). Public scanners such as browserleaks.com/canvas and WebGL report tools are the quickest way to see the shape of your lie.

A Note on Direct GPU Passthrough#

Where hardware is available, the cleanest spoof of all is not to spoof: run the browser on a machine with a real consumer GPU and a real display, so canvas, WebGL, and audio outputs are genuinely produced by consumer hardware. Cloud GPU instances and virtual displays can approximate this, but native consumer hardware remains the least-detectable canvas source. Only fall back to deterministic noise injection when a real GPU is unavailable.

The Probe Space Is Small, the Values Are Not#

Canvas hashing is often treated as a single number to spoof, and that framing causes most of the failures. The useful decomposition is: how many distinct things the probe returns, how you choose the value for each, and how long the value persists.

The probe space is genuinely small. Text rasterisation has a few hundred output pixels for a fixed string at a fixed size, with maybe 8-16 effectively independent bits of variation across a realistic device population — the number of antialiasing modes, hinting configurations and font-cache states. WebGL is larger, on the order of thousands of bytes, because it includes shader compilation results, extension manifests, driver caps tables, parameter enums and formatted float output. But the dimension that matters is not the byte count; it is how many distinct values you need to hold and how the defender samples them.

Three consequences follow, and they are the whole lesson:

  • One fixed value is a fleet-wide constant, and a fleet-wide constant is free to detect no matter how many bytes you return. This is the cardinals problem again, and covers its mechanics.
  • A value that changes per call is worse than useless — it is a positive signal, because the correlation between "same session, different canvas" and "bot" is close to 1.
  • A small pool of values, well chosen, buys you the same protection as a large one, provided the pool reproduces the observed cluster sizes — which is why a fixed pool of 12 is a defensible answer and a pool of 1 is not.

So the engineering question is never "how do I randomise canvas", it is "how do I choose a small number of values that look like real devices, and how do I keep them stable for as long as a real device would be stable".

Four Noise Models and How Each Fails#

Here is the whole space of strategies, measured over 200 sessions with four reads each, against a pool of twelve captured real values per probe:

import hashlib
import random

PROBES = [
    "canvas 2d fillRect",
    "canvas text raster",
    "canvas arc antialias",
    "webgl shader hash",
    "webgl precision",
    "webgl extension list",
    "2d gradient dither",
    "2d shadow blur",
    "webgl parameter enum",
    "2d font kerning",
    "webgl readpixels fmt",
    "2d composite blend",
]

REAL_POOL = {
    "canvas 2d fillRect": ["8c2f1a44", "b71e0933", "04da5f8c", "e39b10ac", "61d0e7b5",
                           "9f4a02de", "37c8b1e6", "ca5904f2", "2e83d617", "b0f4a9c8",
                           "45e1f70b", "fd36c8a1"],
    "canvas text raster": ["41d7b8e0", "7c2ef915", "d904a3e1", "1a8f60d2", "b50c7e34",
                           "63f1a802", "e7d52c9b", "2c86af41", "98b03d76", "4a15e8cf",
                           "cf3092b7", "71e4ad60"],
    "canvas arc antialias": ["90ab34f1", "5e0c7728", "c13f88da", "2b6740ee", "f4083a15",
                             "7d2b5e90", "e6143ac7", "38f9b201", "a25c7f64", "5b8e30d2",
                             "cc41a9fe", "06d3b85a"],
    "webgl shader hash": ["f4a20c31", "3b8d1e57", "6c05a9b2", "e7913fc0", "18d46a35",
                          "b7e2f901", "4a6b8c72", "d90e34af", "25c1b8e3", "8f36d140",
                          "e7042b9a", "52af61dc"],
    "webgl precision": ["0d5e81a9", "a37c4f20", "4826bd71", "c9e05a34", "7b1d38ef",
                        "34a9f205", "e18c6b47", "6d2079a3", "b4f53018", "290de84c",
                        "f73b1a60", "48c6e0d9"],
    "webgl extension list": ["7fb2d014", "24c8e691", "d53170af", "b60ea938", "0c73e5b2",
                             "ea41d786", "3917b0cf", "86fe24a1", "5b0d97c3", "cf802e46",
                             "140ab89d", "a7e60c15"],
    "2d gradient dither": ["1c95a3f7", "e04b2d68", "5f7a1c39", "83d6be02", "3690fc71",
                           "ba47e205", "2ce98d14", "f60b3a9c", "48d1e720", "95a6c03b",
                           "7e3f28da", "0b41d7e6"],
    "2d shadow blur": ["af26c10d", "307e5b94", "94c8d013", "62f1a7ea", "d83b04f5",
                       "19e7ca30", "c5f26b08", "47a0d3e86", "b63c581f", "e0952a74",
                       "3d7eb0c8", "8a41f635"],
    "webgl parameter enum": ["d3197f56", "0c48b2ea", "7ba5d183", "e2604f7c", "35a9c140",
                             "b1d608f9", "6203e7a4", "fd47c38b", "290ae615", "c6843d07",
                             "5e0b9fa2", "a37d1c68"],
    "2d font kerning": ["3f7c8d21", "a92e04bf", "580c3f7a", "cde21068", "16b8e4d9",
                        "7a30c5f2", "e94b0a36", "45f27d81", "0d8a63be", "b3c509f4",
                        "687e2a05", "f21c96d7"],
    "webgl readpixels fmt": ["66d0a839", "1b9f47ce", "84e30b52", "0a72cd95", "e638b02f",
                             "47c1ea60", "b5d3908a", "260af47e", "9de36b15", "c7042ea8",
                             "3a81d6f0", "f5620c7b"],
    "2d composite blend": ["b5e81c30", "291f60d4", "c3073a8e", "4e8b20f7", "83d1e56a",
                           "0f7b3c92", "e248a1d5", "56c9f803", "a30e7b4d", "7d85c216",
                           "1e63af59", "cb2098f4"],
}

rng = random.Random(20260903)
SESSIONS = 200
READS = 4

STRATEGIES = ("per_call_random", "per_profile_hash", "per_session_hash",
              "per_profile_pool", "passthrough")


def sha8(*parts):
    seed = ":".join(str(p) for p in parts)
    return hashlib.sha256(seed.encode("utf-8")).hexdigest()[:8]


def draws(name, session, pool):
    if name == "per_call_random":
        return [pool[rng.randrange(len(pool))] for _ in range(READS)]
    if name == "per_profile_hash":
        return [sha8("profile", session // 10)] * READS
    if name == "per_session_hash":
        return [sha8("session", session)] * READS
    if name == "per_profile_pool":
        return [pool[((session // 10) * 7 + 3) % len(pool)]] * READS
    return [pool[0]] * READS


def measure(name, probe, pool):
    in_session, fleet = [], set()
    for session in range(SESSIONS):
        vals = draws(name, session, pool)
        in_session.append(len(set(vals)))
        fleet.add(vals[0])
    return (sum(in_session) / float(SESSIONS), len(fleet),
            fleet.issubset(set(pool)))


print("Canvas/WebGL spoof strategies: %d probes, %d sessions, %d reads each"
      % (len(PROBES), SESSIONS, READS))
print("")
print("%-18s %13s %14s %18s %14s" % (
    "strategy", "in-session", "fleet values", "drawn from real", "fleet constant"))
print("%-18s %13s %14s %18s %14s" % (
    "", "distinct/probe", "per probe", "population", "across probes"))
metrics = {}
for name in STRATEGIES:
    per_probe = [measure(name, p, REAL_POOL[p]) for p in PROBES]
    in_sess = sum(m[0] for m in per_probe) / len(per_probe)
    fleet_n = sum(m[1] for m in per_probe) / float(len(per_probe))
    in_pop = sum(1 for m in per_probe if m[2]) / float(len(per_probe))
    constant = "yes" if fleet_n == 1.0 else "no"
    metrics[name] = (in_sess, fleet_n, in_pop, constant)
    print("%-18s %13.2f %14.2f %17.0f%% %14s" % (
        name, in_sess, fleet_n, 100.0 * in_pop, constant))
print("")
print("same-session delta is the cheapest detector and it only fires on per_call_random:")
print("  per_call_random in-session distinct per probe   %.2f"
      % metrics["per_call_random"][0])
print("  per_profile_pool in-session distinct per probe  %.2f"
      % metrics["per_profile_pool"][0])
print("")
naive = []
for probe in PROBES:
    base = REAL_POOL[probe][0]
    vals = set()
    for _ in range(READS):
        vals.add(base if rng.random() < 0.5 else sha8("jitter", rng.randrange(10 ** 6)))
    naive.append(len(vals))
print("constant base plus 50%% jitter, distinct values inside one session: mean %.2f"
      % (sum(naive) / float(len(naive))))
print("")
pool_size = len(REAL_POOL[PROBES[0]])
print("a pool of %d real values per probe caps the fleet at %d apparent devices per probe"
      % (pool_size, metrics["per_profile_pool"][1]))
print("profiled pool draw reaches %d of %d pool values across %d session groups"
      % (metrics["per_profile_pool"][1], pool_size, SESSIONS // 10))
print("a hashed profile draw is unique but unpopulated: %d distinct, %.0f%% in population"
      % (metrics["per_profile_hash"][1], 100.0 * metrics["per_profile_hash"][2]))
Canvas/WebGL spoof strategies: 12 probes, 200 sessions, 4 reads each

strategy              in-session   fleet values    drawn from real fleet constant
                   distinct/probe      per probe         population  across probes
per_call_random             3.50          12.00               100%             no
per_profile_hash            1.00          20.00                 0%             no
per_session_hash            1.00         200.00                 0%             no
per_profile_pool            1.00          12.00               100%             no
passthrough                 1.00           1.00               100%            yes

same-session delta is the cheapest detector and it only fires on per_call_random:
  per_call_random in-session distinct per probe   3.50
  per_profile_pool in-session distinct per probe  1.00

constant base plus 50% jitter, distinct values inside one session: mean 3.17

a pool of 12 real values per probe caps the fleet at 12 apparent devices per probe
profiled pool draw reaches 12 of 12 pool values across 20 session groups
a hashed profile draw is unique but unpopulated: 20 distinct, 0% in population

The four failure modes are now separated by two independent columns rather than by intuition, and every strategy fails on at least one of them.

per_call_random scores 3.50 distinct values inside a single session, against 1.00 for every other strategy. A real device's canvas hash is deterministic for a given driver state, so the cheapest possible detector — "did this value change between two reads in the same page load" — catches it immediately. It is not "more random and therefore safer"; it is a strict, free signal.

per_profile_hash and per_session_hash are stable and unique, and 0% of their values exist in the real population. Per-session is unique per session but stable within it, which sounds ideal and is not: it produces a fleet that never repeats a value, which is the mirror image of the constant-fleet problem and equally diagnostic. The 0% in population column is the point: a hash of your own device seed is a value no real GPU has ever produced, so any defender holding a capture rejects it.

passthrough is stable, in-population, and the only strategy where the whole fleet collapses to 1.00. Returning the real hardware value is not neutral — the real hardware value is the same value on every machine of the same model, which is why unmodified headless Chrome on a datacentre carries a canvas hash that correlates with the IP.

per_profile_pool is the only row with 1.00 in-session and 100% in-population. Variety at the fleet level, stability at the session level, and every value attested in the real population.

The Stability / Variety Trade You Cannot Dodge#

The trade is forced, and understanding it prevents an infinite tuning loop.

Choose per-session stability and you need enough distinct values that the fleet does not collapse to a constant. The cost is that a value which changes mid-session is detectable, so you must hold it for at least a page load, ideally for a browsing session.

Choose per-profile stability — one value per device profile, held for the profile's life — and you get the best of both: stable within and across sessions for that profile, and varied across the fleet. That is per_profile_pool, which in the snippet draws from the twelve-value pool with a fixed step so twenty session groups spread across all twelve values.

The ceiling is pool size, and this is the constraint that ends the argument. Twelve real values per probe means the fleet can look like at most twelve devices per probe, no matter how many sessions you run. That is fine for a small fleet and fatal for a large one: a thousand-profile fleet sampling twelve values presents a device-count histogram with a hard ceiling and no tail, which is a different anomaly from a constant but the same family of tell. The fix is not more strategies, it is a larger attested pool — which means captures, not clever hashing.

The other half of the ceiling is cluster shape. Real devices are not uniformly distributed across canvas values; they cluster by GPU, driver version and OS build, with the largest cluster several times the size of the median one. A pool sampled uniformly gives you a flat histogram and a moderately suspicious fleet; a pool that reproduces the observed cluster sizes does not. This is the same argument as for fonts in and for the whole profile bundle in .

One more constraint: do not choose values per probe. Independent per-probe draws are wrong, because the twelve probes come from one driver, so their joint distribution is what has to look real. Choosing each probe's value independently produces a bundle where webgl shader hash suggests driver A and canvas text raster suggests driver B. Draw the whole bundle per profile from a captured device record, never per probe.

WebGL Has a Wider Surface Than Canvas#

Canvas 2D gives you one surface — the rasterised pixels of a text or shape string. WebGL gives you six or seven, and each is independently exploitable:

  • WEBGL_debug_renderer_info — UNMASKED_VENDOR_WEBGL and UNMASKED_RENDERER_WEBGL. Usually overridden, and if the override is inconsistent with the reported platform it is a contradiction. Checked in .
  • Shader source and compile behaviour. gl.getShaderInfoLog output and the exact gl.getParameter results for precision queries (HIGH_FLOAT, MEDIUM_FLOAT) vary by driver. A shader compiling synchronously on one driver and asynchronously on another is a timing signal independent of any value.
  • Extension manifest. WEBGL_lose_context, EXT_texture_filter_anisotropic maximum anisotropy, WEBGL_compressed_texture_s3tc presence, KHR_parallel_shader_compile, EXT_color_buffer_float. The set is platform-determined, so it cross-validates against the UA.
  • Parameter enumerants. MAX_TEXTURE_SIZE, MAX_RENDERBUFFER_SIZE, MAX_VERTEX_UNIFORM_VECTORS, MAX_VIEWPORT_DIMS, SUBPIXEL_BITS, ALIASED_POINT_SIZE_RANGE. These are GPU-model-specific and are a stronger fingerprint than the renderer string, because far fewer people spoof them.
  • readPixels output format. The exact bytes and their floating-point rounding behaviour are driver-implementation-specific. readPixels with an RGBA8 buffer is a cheap, high-entropy probe.
  • Canvas and media codec support, which shares the hardware-availability logic with WebGL and is covered in .

The operational consequence is that WebGL exposes more surfaces, so a naive WebGL override that only patches the renderer string leaves five or six intact. The canonical finding in the browser-fingerprint literature is that the unmasked renderer string plus a parameter enum is enough to separate device classes, and neither requires canvas at all — see .

The second consequence is that WebGL is much better suited to the per-profile draw than canvas: because it has more independent dimensions, you can pick values that are individually rare and jointly consistent, and the joint consistency is checkable against a corpus.

What Defeats Naive Constant-Value Spoofing#

Four specific attacks, in increasing order of cost to the defender and therefore decreasing order of how well they work.

Pool-exhaustion detection. Collect the set of canvas hashes seen across your sessions. One value across the fleet is flagged immediately. This is the cheapest detector and it kills the naive constant — it is exactly what the fleet constant column reports for passthrough.

Correlation with a third-party value. A site embeds a known-good reference fingerprint provider, or uses a public fingerprint database, and joins the canvas hash against a matching device record. Now your pool has to consist of hashes that genuinely exist in the population, in the right proportion, with the right joint profile. This is what the 0% in population column measures, and it is why spoofing a hash because someone posted it in a forum is worse than randomising: it is a value with an attributable population.

Temporal drift. Real canvas values change when the driver updates, when the OS updates, when the screen scale changes, when a GPU driver bug fix lands. A value that never changes over 90 days is not a device. This is the version-recency argument from , and it is why a profile needs a device-age model rather than a fixed seed — see .

Cross-session join. The site correlates the canvas hash against a first-party identifier. A hash appearing on 4,000 sessions from distinct IPs is a device-count anomaly, not just a value anomaly, and it escalates the risk score rather than tripping a rule.

The last two are why the fleet-shaped architecture matters: they are only visible at fleet scale, so they are only detectable at fleet scale, and they are the mechanism by which a technically-perfect per-session spoof gets caught in production. The measurement discipline is in and .

Testing the Four Properties#

Your canvas layer passes only if all four hold. Test them explicitly, in a harness, per profile, on a schedule.

Property 1 — determinism. The same profile returns the same hash for the same probe string, across calls, across page loads, across processes, across restarts, across an unintended browser version bump. Test by collecting the probe in a loop and asserting a single value; test across a fresh process to catch state that only exists in a warm font or shader cache.

Property 2 — population plausibility. Every value is in a populated cell of the real distribution, and the fleet's histogram over values matches the real cluster-size profile — including the ceiling. Test by comparing your fleet histogram against a capture, by checking that no single value exceeds a plausible share, and by checking that the maximum number of distinct values is consistent with your fleet size.

Property 3 — joint consistency. Canvas, WebGL renderer, WebGL parameter enums, extension manifest, media codec support and the UA all point at the same physical device. Test by cross-checking the bundle against a device record for that model, field by field — this is the device-profile audit with canvas as one more column.

Property 4 — temporal behaviour. The value drifts at the rate real values drift: not per call, not per session, occasionally and plausibly over weeks, correlated with the same events that would affect a real device. Test by recording the value daily for a month and checking the drift rate.

Property 3 is where most implementations fail silently, because it is the only one that requires a reference corpus rather than self-consistency. Without captures from real devices — or the synthetic corpus described in — you can only test properties 1 and 4, which is a real gap: you can prove your spoof is stable, but not that it is plausible. The implementation mechanics, and the failure mode where the patch is more detectable than the value, are in .

Why Randomisation Is the Default Mistake#

It is worth naming the instinct explicitly, because it is the wrong one and it is what most first implementations do. Canvas is opaque, so randomise it; WebGL is opaque, so randomise the renderer string. The reasoning is that randomness is indistinguishable from a large population, and it is wrong in three separate ways that compound.

Randomising per call is a free detection. The per_call_random row in the table above scores 3.50 distinct values inside a single session. No real device rasterises the same string differently twice in one page load, so "did this change between two reads" is a one-line detector that catches every per-call implementation. You have converted a weak signal into a strong one.

Uniform randomisation is unpopulated. Even a random value that is stable per session lands in a region of the value space with no devices in it, because the real distribution is heavily clustered — a handful of GPU and driver combinations produce the large majority of observed hashes. A value from the tail is as anomalous as a constant, and unlike a constant it is novel every time, so the defender's first sighting of it is guaranteed to be unique.

Randomisation is unstable by construction. Property 4 above requires drift at the rate real devices drift. Randomising gives you either a value that never changes or one that changes unpredictably, and neither is what a device does. There is no randomisation strategy that produces the specific, low-frequency, event-correlated drift of a driver update.

The correct framing is the one this section has been building toward: canvas and WebGL are populated-cell problems, not entropy problems. The task is to build a model of the device population, draw a bundle from it, and then live inside that bundle for as long as the draw remains plausible. Everything in the four noise models above follows from that framing, and the pool column in the output — 100% versus 0% — is the single measurement that tells you whether you have done it.

The reason the instinct is so strong is that the alternative sounds like over-engineering: sampling from a twelve-value pool feels like less work than generating a value. It is not, because it requires having the pool, and having the pool requires captures, and captures require instrumentation you probably do not have yet. That is worth knowing before committing to either path.