What WebGL exposes
The browser API exposes four classes of identifying data:
- Renderer string — the GPU's name, read via
gl.getParameter(gl.RENDERER)with theWEBGL_debug_renderer_infoextension. Returns strings like"ANGLE (NVIDIA, NVIDIA GeForce RTX 4070 Direct3D11 vs_5_0 ps_5_0, D3D11)"on Windows or"Apple GPU"on macOS. - Extension list — about 70 named extensions (
EXT_color_buffer_float,OES_texture_float_linear, etc.), the optional features your GPU and driver support. The exact set varies by GPU model and driver version. - Parameter values — limits the hardware reports, such as max texture size (4K/8K/16K), max viewport dimensions, max vertex attributes, and fragment shader precision (how accurate the GPU's per-pixel maths is). Each varies by hardware tier.
- Rendered output — draw a known shape with a known shader (a small GPU program) and hash the pixels read back from the canvas into a short ID. Different GPUs produce subtly different floating-point rounding, anti-aliasing (edge smoothing), and gradient interpolation, so the ID is steady but machine-specific.
Why headless browsers fail WebGL the hardest
A headless browser runs with no visible window, the usual setup for automation. Headless Chrome without a GPU falls back to SwiftShader, Google's software rasterizer - a stand-in that draws graphics on the CPU instead of a real GPU. The renderer string is then "Google Inc. SwiftShader" or "Google SwiftShader" — anti-bot vendors block this string unconditionally because no real desktop user has SwiftShader as their primary GPU. The fallback chain is even worse on Linux: llvmpipe (Mesa's software rasterizer) is an instant tell.
Running headless Chrome with --use-gl=angle --use-angle=swiftshader-webgl still produces a SwiftShader renderer. The mitigations are: (1) run with xvfb (a virtual display) plus a real GPU passed through, (2) spoof WEBGL_debug_renderer_info at the CDP level (Chrome's remote-control protocol) with a believable renderer string, or (3) use a tool like Camoufox or CloakBrowser that patches the renderer at the C++ level (inside the browser engine itself).
The renderer-platform coherence trap
Spoofing the renderer string alone is insufficient. Anti-bot vendors cross-check the claimed renderer against the platform (navigator.platform, navigator.userAgent) and the GPU's expected extension list - in other words, do all the clues agree with each other? A request claiming Windows + Chrome 131 with renderer string "Apple GPU", or macOS with an NVIDIA RTX renderer, gets blocked. The renderer extension set must also match the claimed hardware — a budget integrated GPU advertising the extensions only found on enthusiast cards is a clear tell.
Camoufox's approach is to maintain a database of real (platform, GPU, renderer, extensions, parameters) tuples harvested from real users, and serve a coherent one per session, so every value matches. This is more expensive than spoofing individual values, which is why DIY hardening of WebGL almost always trips at least one cross-check.
Five ways to ask the same GPU the same question
The renderer string is the best-known WebGL signal and the least reliable one, because it is a single value that is easy to change. The checks that carry weight compare independent readings of the same hardware.
| Comparison | Why they must agree |
|---|---|
| WebGL 1 vs WebGL 2 | Two contexts, one physical GPU - the unmasked renderer must be identical in both |
| Main thread vs Worker | OffscreenCanvas gives a Worker a real context, reading the GPU from a separate realm |
| WebGL vs WebGPU | Two graphics APIs describing one adapter - vendor and tier must match |
| Renderer string vs driver limits | A named GPU implies a known range for texture size, uniform vectors, and extension list |
| Renderer string vs shader backend | The shading-language dialect and precision behaviour follow the actual backend in use |
The last two are the most demanding, because they test whether the hardware named in a string can actually do what that hardware does. A context claiming a current discrete GPU while reporting a maximum texture dimension typical of a software rasteriser has made a claim its own capabilities contradict. Likewise, advertised extensions should be instantiable - listing an extension that fails when requested is a list that was not produced by the driver it claims to come from.
Readback integrity: what the pixels themselves prove
A separate family of checks ignores metadata entirely and examines rendered output for internal consistency. These target per-session output perturbation - the practice of adding small variations to canvas and WebGL readings so the resulting hash differs between visits.
The probes are deliberately trivial. Clear a buffer to a single flat colour and read it back: every pixel must be exactly that colour. Read the same region through two different paths - readPixels and toDataURL, or getImageData on overlapping sub-rectangles - and the overlapping pixels must be identical. Draw the same flat colour twice, once by clearing and once through a trivial shader, and the results must match.
Uniform noise fails these immediately, because a flat fill stops being flat. Noise applied at one readback path but not another fails the cross-path comparison. The underlying issue is that perturbation has to be indistinguishable from real rendering variation, but real rendering has no variation at all for a flat clear - the correct answer is exactly one value, and any deviation is arithmetically wrong rather than merely unusual.
The same logic extends to text and layout measurement, where advance widths must be additive across repeated glyphs and metrics must scale consistently, and to audio, where rendering silence must produce exact zeros. In every case the check is not "is this value rare" but "is this value arithmetically possible", which is why output perturbation has aged poorly as an approach compared with rendering honestly on hardware whose capabilities match the identity being presented.
