Reading layout

Spoofing Canvas and WebGL Fingerprints

Canvas and WebGL are the two highest-entropy signals a page can read without asking permission, and they are the surfaces most often patched badly; this page goes a level deeper than Browser Fingerprint and Stealth Configuration into how the hashes are built and what a defensible patch looks like.

How a canvas fingerprint hash is produced Drawing commands are rasterised by the GPU and driver, serialised to PNG bytes, hashed, and reduced to a bucket identifier. Only the rasterisation step differs between machines. Same script, different machines, different hashDraw opstext + arcRasteriseGPU, drivertoDataURLPNG bytesHashSHA-256Bucket ID3f9a…c1the only step that varies
The script is identical on every machine; only the rasterisation step varies, which is exactly why the resulting hash identifies the hardware.

The conclusion first, because it inverts most advice: do not randomise per request. A fingerprint that changes on every page load is a stronger detection signal than any single common value, because real hardware is deterministic — the same machine returns the same hash every time. What you want is one coherent, plausible, stable profile per browser identity: a GPU vendor and renderer string that a real machine would report, a canvas hash that stays fixed for the life of that identity, and a claimed platform and User-Agent that agree with both. Change the whole profile when you change identity, never within a session.

How the Two Hashes Are Computed

A canvas fingerprint is a rendering test. The script creates an off-screen <canvas>, draws a fixed sequence of operations — a line of text in a named font at a fractional offset, an emoji, a couple of overlapping arcs with alpha blending and a globalCompositeOperation — and then serialises the result with toDataURL() or reads the raw pixels with getImageData(). Those bytes are hashed, and the hash becomes an identifier.

The script is byte-identical on every visitor's machine. What varies is the rasterisation: which GPU and driver performed the compositing, how anti-aliasing and sub-pixel positioning were resolved, which font file the system actually supplied for the named family, and how the emoji glyph was rendered. Change any of those and the pixels shift, usually by only a few least-significant bits, and the hash changes completely. Typical entropy for canvas alone is around 8 to 10 bits in the general population — not identifying on its own, but very effective in combination.

The WebGL fingerprint has two independent parts. The first is declarative: the WEBGL_debug_renderer_info extension exposes UNMASKED_VENDOR_WEBGL and UNMASKED_RENDERER_WEBGL, which return strings like Google Inc. (NVIDIA) and ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 Direct3D11 vs_5_0 ps_5_0, D3D11). Alongside those sit dozens of numeric limits from getParameter — maximum texture size, vertex attribute count, supported extension list — which together form a GPU model signature. The second part is a rendered hash, the same idea as canvas but produced by drawing a shaded 3D scene and reading the framebuffer, which captures driver-level differences in shader precision.

A detector does not usually treat these as a unique identifier. It buckets them, and asks two things: is this bucket plausible, and does it agree with the other signals?

Why Randomising Every Load Is a Signal

Per-load randomisation compared with one stable profile Four consecutive page loads produce four different canvas hashes when the fingerprint is randomised, and one repeated hash when a single coherent profile is presented. Randomised per loadOne coherent profileload 1 → canvas 9f3a…load 2 → canvas 41cd…load 3 → canvas e07b…load 4 → canvas b2f8…no real device does thisload 1 → canvas 9f3a…load 2 → canvas 9f3a…load 3 → canvas 9f3a…load 4 → canvas 9f3a…shared with many real users
Real hardware produces the same hash on every load. Re-randomising per request creates a signature no genuine device has, which is easier to detect than a common value.

The common advice is to add noise to canvas output so you cannot be tracked. The reasoning is sound for privacy and wrong for scraping, because the two threat models are opposites. A privacy tool wants to be unlinkable across sites. A scraper wants to be indistinguishable from an ordinary visitor. Randomising achieves the first and destroys the second.

Consider what a detector sees. A real device returns hash 9f3a… on every load, forever. A naively patched automation session returns 9f3a…, then 41cd…, then e07b…. No hardware behaves that way. Detecting it needs no reference database at all: hash the canvas twice on the same page, or once on the landing page and once after navigation, and compare. A mismatch within one session is close to a proof of instrumentation, and it costs the detector nothing to check.

The second-order version is subtler. Some libraries add per-pixel noise that is stable within a page load but drawn from a distribution that never occurs naturally — for example, uniform noise in the least significant bit of every channel, where genuine rasterisation differences are spatially correlated and concentrated at glyph edges. A detector that keeps the raw image rather than only the hash can test for that statistically.

There is a real cost to sitting still, and it is worth stating plainly: a stable fingerprint is linkable. If a site records your canvas hash and later bans it, every session presenting that hash is banned together. The resolution is to make the identity the unit of rotation. One worker gets one coherent profile — GPU strings, canvas seed, User-Agent, platform, locale, timezone, viewport — and keeps it for that identity's whole lifetime, across many page loads and sessions. When you retire the identity, you retire all of it at once. That is how real fleets of machines look: many distinct devices, each internally consistent.

Coherence Between the Claimed Platform and the Reported GPU

Consistency between claimed platform and reported GPU renderer A Windows user agent paired with a discrete NVIDIA renderer is coherent. The same user agent paired with a software renderer, or with an Apple GPU string, is flagged. Signals that have to agreeWindows UA · Win32 platformANGLE (NVIDIA GeForce RTX 3060 Direct3D11)coherentWindows UA · Win32 platformANGLE (Google SwiftShader, software device)flaggedWindows UA · Win32 platformApple GPU (Apple M2, Metal)flagged
Detectors cross-check the claimed platform against the reported GPU string; a Windows user agent backed by SwiftShader or an Apple GPU is a contradiction no real machine produces.

Most fingerprint patches fail on consistency rather than on any individual value, and the GPU string is where headless automation gives itself away most reliably.

Headless Chrome without GPU access falls back to a software rasteriser, and the renderer string says so: ANGLE (Google SwiftShader, SwiftShader Device (Subzero), SwiftShader driver). SwiftShader is essentially never present on a consumer desktop that is simultaneously claiming a Windows 10 User-Agent and a 1920×1080 viewport. It is a single string that identifies a headless container, and no amount of canvas noise hides it.

The opposite error is over-correction. Spoofing an Apple GPU string while navigator.platform reports Win32 and the User-Agent says Windows NT 10.0 is a contradiction, and it is a cheap one to test — a detector just checks whether the renderer family belongs to the claimed operating system. Likewise, a renderer claiming a discrete NVIDIA card should come with a MAX_TEXTURE_SIZE and extension list consistent with that hardware generation; patching only the two strings while leaving the software rasteriser's numeric limits underneath is a mismatch that a thorough script will find.

The rules that follow are simple. Pick a real, popular hardware configuration and copy its complete signature, not two strings from it. Keep the GPU family inside the claimed operating system's universe — ANGLE/Direct3D for Windows, Metal or Apple GPU for macOS, OpenGL for Linux. Match the tier to the rest of the profile: a mid-range integrated GPU is the most common real configuration and therefore the best camouflage, whereas a top-end card paired with a small viewport and two CPU cores is incongruous. And enable actual GPU rendering where you can — running headful under a virtual display, or launching with --use-gl=angle --use-angle=swiftshader-webgl deliberately replaced by real hardware access, produces genuine values that need no patching at all.

A Coherent Playwright Init Script

The script below builds one profile, derives a deterministic canvas seed from it, and injects patches before any page JavaScript runs. The canvas patch applies a tiny, seed-stable perturbation to a handful of pixels so the hash differs from the container default but is identical on every load of the same profile. The WebGL patch reports a matching vendor and renderer.

import hashlib
from dataclasses import dataclass

from playwright.sync_api import sync_playwright


@dataclass(frozen=True)
class GpuProfile:
    """One internally consistent hardware identity."""
    identity: str
    user_agent: str
    platform: str
    vendor: str
    renderer: str

    def seed(self) -> int:
        """A stable per-identity seed, so the canvas hash never moves."""
        digest = hashlib.sha256(self.identity.encode("utf-8")).digest()
        return int.from_bytes(digest[:4], "big")


WINDOWS_NVIDIA = GpuProfile(
    identity="worker-07",
    user_agent=(
        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
        "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
    ),
    platform="Win32",
    vendor="Google Inc. (NVIDIA)",
    renderer=(
        "ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 Direct3D11 vs_5_0 ps_5_0, D3D11)"
    ),
)

INIT_SCRIPT = """
(({ seed, vendor, renderer, platform }) => {
  Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
  Object.defineProperty(navigator, 'platform', { get: () => platform });

  let state = seed >>> 0;
  const nextByte = () => {
    state = (state * 1664525 + 1013904223) >>> 0;
    return state >>> 24;
  };

  const shift = (canvas) => {
    const ctx = canvas.getContext('2d');
    if (!ctx || !canvas.width || !canvas.height) return;
    const image = ctx.getImageData(0, 0, canvas.width, canvas.height);
    for (let i = 0; i < 24; i += 1) {
      const px = (nextByte() * 4) % image.data.length;
      image.data[px] = (image.data[px] + (nextByte() % 2)) % 256;
    }
    ctx.putImageData(image, 0, 0);
  };

  const toDataURL = HTMLCanvasElement.prototype.toDataURL;
  HTMLCanvasElement.prototype.toDataURL = function (...args) {
    state = seed >>> 0;
    shift(this);
    return toDataURL.apply(this, args);
  };

  const getParameter = WebGLRenderingContext.prototype.getParameter;
  const patched = function (parameter) {
    if (parameter === 37445) return vendor;
    if (parameter === 37446) return renderer;
    return getParameter.call(this, parameter);
  };
  WebGLRenderingContext.prototype.getParameter = patched;
  if (typeof WebGL2RenderingContext !== 'undefined') {
    WebGL2RenderingContext.prototype.getParameter = patched;
  }
})(SETTINGS);
"""


def read_fingerprint(profile: GpuProfile, url: str) -> dict[str, str]:
    """Load a page with one coherent profile and report what it exposes."""
    settings = {
        "seed": profile.seed(),
        "vendor": profile.vendor,
        "renderer": profile.renderer,
        "platform": profile.platform,
    }
    script = INIT_SCRIPT.replace("SETTINGS", repr(settings).replace("'", '"'))

    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True, args=["--headless=new"])
        context = browser.new_context(
            user_agent=profile.user_agent,
            viewport={"width": 1920, "height": 1080},
            locale="en-US",
            timezone_id="America/New_York",
        )
        context.add_init_script(script)
        page = context.new_page()
        page.goto(url, wait_until="domcontentloaded", timeout=30_000)
        result = page.evaluate(
            """() => {
                const c = document.createElement('canvas');
                c.width = 240; c.height = 60;
                const ctx = c.getContext('2d');
                ctx.textBaseline = 'alphabetic';
                ctx.font = '16px Arial';
                ctx.fillStyle = '#f60';
                ctx.fillRect(10, 10, 90, 30);
                ctx.fillStyle = '#069';
                ctx.fillText('fingerprint 0123', 12.5, 40.7);
                const gl = document.createElement('canvas').getContext('webgl');
                return {
                    canvas: c.toDataURL().slice(-32),
                    vendor: gl ? gl.getParameter(37445) : 'no webgl',
                    renderer: gl ? gl.getParameter(37446) : 'no webgl',
                    platform: navigator.platform,
                };
            }"""
        )
        browser.close()
        return result


if __name__ == "__main__":
    first = read_fingerprint(WINDOWS_NVIDIA, "https://example.com")
    second = read_fingerprint(WINDOWS_NVIDIA, "https://example.com")
    print(first)
    print("stable across loads:", first["canvas"] == second["canvas"])

Run it twice and the canvas tail is identical both times, which is the point — the profile is stable. Change identity to worker-08 and the hash moves, because that is a different machine. The state = seed reset inside toDataURL matters: without it, a page that serialises the canvas twice would get two different results and hand a detector the exact within-session inconsistency you are trying to avoid.

Verify against a fingerprint test page rather than trusting the patch. The values reported by the page are the only evidence that matters, and the mechanics of applying broader patch sets are covered in undetected-chromedriver vs playwright-stealth.

Edge Cases and Caveats

  • getImageData is a second read path. Patching only toDataURL leaves getImageData and toBlob returning unmodified pixels. A script that hashes via getImageData sees the container's real output while toDataURL shows the patched one, and the disagreement is itself detectable.
  • OffscreenCanvas and workers bypass main-thread patches. OffscreenCanvas inside a web worker has its own realm; an init script that patches HTMLCanvasElement.prototype alone does not reach it.
  • The patch itself is visible. HTMLCanvasElement.prototype.toDataURL.toString() returns the function source. Native functions report [native code]; a replacement does not unless you also patch Function.prototype.toString.
  • Font availability drives canvas output. A minimal container missing common fonts falls back to a substitute, so the rendered text differs from any real desktop regardless of pixel patching. Install a normal desktop font set in the image.
  • Numeric WebGL limits give away SwiftShader. Spoofing the two renderer strings while MAX_TEXTURE_SIZE, MAX_VIEWPORT_DIMS and the extension list still match a software rasteriser is an internal contradiction. Copy the whole parameter set or use real GPU access.
  • The IP has to agree too. A convincing consumer GPU profile arriving from a cloud range is still incoherent; pair it with an appropriate exit as described in rotating proxies and managing IP blocks.

Frequently Asked Questions

Should I randomise my canvas fingerprint on every request? No. Real hardware is deterministic, so a hash that changes between loads is a stronger signal than any common value — and a detector can test it by hashing the canvas twice on one page. Keep the fingerprint stable for the lifetime of an identity and rotate the entire profile when you rotate the identity.

Is adding noise better than reporting a common profile? Presenting one coherent, popular configuration is generally safer. Noise has to be shaped like genuine rasterisation differences, which are spatially correlated around glyph edges rather than uniform across the image, and a detector holding the raw pixels can test that distribution.

Why does my headless browser get flagged even with canvas patched? Most often the WebGL renderer still reports SwiftShader, the software rasteriser headless Chrome falls back to without GPU access. That string is effectively an automation marker on a machine claiming a normal desktop User-Agent, and no canvas patch conceals it.

Do I need to patch anything if I run headful with a real GPU? Usually much less. A headful browser on real hardware produces genuine canvas and WebGL values that need no spoofing at all, which is why running under a virtual display is the most faithful option when a target is strict — at the cost of higher memory use per worker.