WebGL fingerprinting: bypass and modern defenses
WebGL fingerprinting is canvas fingerprinting’s heavier cousin. Instead of measuring how a browser rasterizes 2D text, it asks the GPU to render a 3D scene and reads back the pixels, then also queries dozens of GPU and driver parameters via the WebGL API. The result is a fingerprint that is much more discriminating than the canvas equivalent because real GPUs differ in driver version, vendor, ANGLE backend, and supported extensions in ways that are hard to fake. Headless Chrome containers, in particular, are dead simple to identify by WebGL because they almost universally report SwiftShader or Mesa software rasterizer.
This guide covers what WebGL fingerprinting actually queries, why simple getParameter overrides are detectable, and the patterns that survive enterprise checks in 2026. Code targets Playwright with Chromium, but the principles port to any automation stack.
What WebGL exposes
WebGL is a JavaScript API based on OpenGL ES, exposing the GPU to web content. Fingerprinters use three layers of WebGL inspection:
- Direct parameter queries via
gl.getParameter()for renderer, vendor, version, supported extensions - Capability queries for max texture size, max viewport dimensions, antialiasing support, anisotropic filtering levels
- Render-and-read which renders a scene and hashes the pixel buffer, similar to canvas but with 3D primitives
The most-queried parameters in 2026:
| parameter | typical Chrome on Win | typical headless container |
|---|---|---|
| UNMASKED_VENDOR_WEBGL | Google Inc. (NVIDIA) | Google Inc. (Google) |
| UNMASKED_RENDERER_WEBGL | ANGLE (NVIDIA, GeForce RTX 3060…) | ANGLE (Google, Vulkan 1.3.0…SwiftShader Device) |
| VERSION | WebGL 2.0 (OpenGL ES 3.0 Chromium) | WebGL 2.0 (OpenGL ES 3.0 Chromium) |
| SHADING_LANGUAGE_VERSION | WebGL GLSL ES 3.00 | WebGL GLSL ES 3.00 |
| MAX_TEXTURE_SIZE | 16384 | 8192 or 16384 |
| MAX_VIEWPORT_DIMS | 32767, 32767 | varies |
| MAX_VERTEX_ATTRIBS | 16 | 16 |
| ALIASED_LINE_WIDTH_RANGE | 1, 1 (or 1, 7 on some drivers) | 1, 1 |
The killer fields are UNMASKED_VENDOR_WEBGL and UNMASKED_RENDERER_WEBGL. A real desktop typically returns “Google Inc. (NVIDIA)” or “Google Inc. (Intel)” with an ANGLE wrapper, while a headless container returns “Google Inc. (Google)” with SwiftShader, Vulkan, or LLVMpipe. That single string difference is the most reliable bot signal in WebGL fingerprinting.
Why naive overrides fail
The first attempt every scraper makes is to override WebGLRenderingContext.prototype.getParameter to lie about renderer and vendor. Fingerprinters detect this by:
- Checking that
getParameter.toString()returns native code - Calling
getParameterwith a parameter that the override forgot to handle, then seeing if the response shape is consistent - Cross-checking the claimed vendor against capabilities (a GeForce RTX 3060 should support certain extensions and texture sizes; if the capabilities do not match the claim, that is a flag)
- Using both WebGLRenderingContext and WebGL2RenderingContext, since some overrides only patch one
- Using OffscreenCanvas WebGL, which has its own context prototype
A complete bypass needs to override both contexts, handle every parameter consistently, match capabilities to the claimed renderer, and pass the toString integrity check.
Bypass approach 1: full WebGL parameter spoofing
The clean pattern in 2026 is to pick a target GPU profile (real device that you want to impersonate), define every parameter consistently with that GPU, and hook all three context types. Inject this via Playwright’s add_init_script.
(() => {
// Target: Intel UHD Graphics 630 on Windows 10
const gpuProfile = {
vendor: "Google Inc. (Intel)",
renderer: "ANGLE (Intel, Intel(R) UHD Graphics 630 Direct3D11 vs_5_0 ps_5_0, D3D11)",
maxTextureSize: 16384,
maxRenderbufferSize: 16384,
maxVertexAttribs: 16,
maxVaryingVectors: 31,
maxFragmentUniformVectors: 1024,
maxVertexUniformVectors: 4096,
aliasedLineWidthRange: new Float32Array([1, 1]),
aliasedPointSizeRange: new Float32Array([1, 1024]),
};
const PARAM_MAP = {
37445: gpuProfile.vendor, // UNMASKED_VENDOR_WEBGL
37446: gpuProfile.renderer, // UNMASKED_RENDERER_WEBGL
3379: gpuProfile.maxTextureSize, // MAX_TEXTURE_SIZE
34024: gpuProfile.maxRenderbufferSize, // MAX_RENDERBUFFER_SIZE
34921: gpuProfile.maxVertexAttribs, // MAX_VERTEX_ATTRIBS
36347: gpuProfile.maxVaryingVectors, // MAX_VARYING_VECTORS
36349: gpuProfile.maxFragmentUniformVectors, // MAX_FRAGMENT_UNIFORM_VECTORS
36347: gpuProfile.maxVertexUniformVectors, // MAX_VERTEX_UNIFORM_VECTORS
33902: gpuProfile.aliasedLineWidthRange, // ALIASED_LINE_WIDTH_RANGE
33901: gpuProfile.aliasedPointSizeRange, // ALIASED_POINT_SIZE_RANGE
};
const patchedFns = new WeakSet();
const wrapGetParameter = (proto) => {
const original = proto.getParameter;
proto.getParameter = function (param) {
if (PARAM_MAP[param] !== undefined) {
return PARAM_MAP[param];
}
return original.apply(this, arguments);
};
patchedFns.add(proto.getParameter);
};
if (window.WebGLRenderingContext) {
wrapGetParameter(WebGLRenderingContext.prototype);
}
if (window.WebGL2RenderingContext) {
wrapGetParameter(WebGL2RenderingContext.prototype);
}
// Hook Function.prototype.toString to make patched functions look native
const nativeToString = Function.prototype.toString;
Function.prototype.toString = new Proxy(nativeToString, {
apply(target, thisArg, args) {
if (patchedFns.has(thisArg)) {
const name = thisArg.name || 'getParameter';
return `function ${name}() { [native code] }`;
}
return Reflect.apply(target, thisArg, args);
},
});
})();
The PARAM_MAP needs every parameter that fingerprinters might query. The list above covers the most common ones, but enterprise vendors query 30+ parameters. Use a reference fingerprint from a real Intel UHD 630 (or whatever GPU you are impersonating) to fill in every value consistently. A mismatch between vendor claim and capability list is itself a flag.
Bypass approach 2: noise injection on render-and-read
Beyond parameter queries, fingerprinters also render a small 3D scene and hash the pixel buffer via gl.readPixels. Override readPixels to add tiny per-context noise:
(() => {
const seed = (() => {
if (window.__webglSeed === undefined) {
window.__webglSeed = Math.floor(Math.random() * 1e9);
}
return window.__webglSeed;
})();
const xorshift = (n) => {
n ^= n << 13;
n ^= n >>> 17;
n ^= n << 5;
return n >>> 0;
};
const wrapReadPixels = (proto) => {
const original = proto.readPixels;
proto.readPixels = function (x, y, width, height, format, type, pixels) {
original.apply(this, arguments);
if (pixels && pixels.byteLength) {
let key = seed ^ x ^ (y << 8) ^ (width << 16);
for (let i = 0; i < pixels.byteLength; i += 4) {
key = xorshift(key + i);
if (i < pixels.length) pixels[i] = (pixels[i] + ((key % 5) - 2)) & 0xff;
if (i + 1 < pixels.length) pixels[i + 1] = (pixels[i + 1] + (((key >> 8) % 5) - 2)) & 0xff;
if (i + 2 < pixels.length) pixels[i + 2] = (pixels[i + 2] + (((key >> 16) % 5) - 2)) & 0xff;
}
}
};
};
if (window.WebGLRenderingContext) {
wrapReadPixels(WebGLRenderingContext.prototype);
}
if (window.WebGL2RenderingContext) {
wrapReadPixels(WebGL2RenderingContext.prototype);
}
})();
Same principle as canvas noise: small per-pixel offsets keyed by a session seed produce a unique-but-stable WebGL fingerprint per scraping context.
Bypass approach 3: patchright with built-in WebGL spoofing
Patchright (Playwright stealth fork) ships WebGL spoofing out of the box. You point it at a profile, and it handles parameter overrides, render noise, and the toString integrity check.
from patchright.async_api import async_playwright
async def fetch_with_webgl_spoof(url, profile="intel_uhd_630"):
async with async_playwright() as p:
browser = await p.chromium.launch(
headless=True,
args=[
"--use-gl=angle",
"--use-angle=swiftshader", # consistent ANGLE backend
"--disable-blink-features=AutomationControlled",
],
)
ctx = await browser.new_context(
viewport={"width": 1920, "height": 1080},
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",
)
page = await ctx.new_page()
await page.goto(url, wait_until="networkidle")
return await page.content()
Patchright applies the spoof per context, so multiple contexts in the same browser get different (or the same, configurable) GPU profiles. For most teams in 2026, this is the path of least resistance.
Bypass approach 4: use a real GPU runtime
If you have access to actual GPU hardware (consumer GPUs in your scraping infrastructure), the cleanest WebGL fingerprint is the real one. Run Chrome with hardware acceleration enabled on a machine with a real GPU. The fingerprint matches what real users see because it is what real users see.
This is impractical for most cloud scraping (cloud GPUs are expensive and not designed for browser workloads), but for high-stakes targets, it eliminates the entire fingerprinting question. Some scraper-focused providers like Browserbase offer this via their hosted browsers running on real hardware.
Comparison: what each approach gets you
| approach | UNMASKED_VENDOR | UNMASKED_RENDERER | render hash | extension list |
|---|---|---|---|---|
| naive override | spoofed | spoofed | unchanged | inconsistent |
| full parameter spoof | spoofed | spoofed | unchanged | matched |
| parameter + noise | spoofed | spoofed | randomized | matched |
| patchright | spoofed | spoofed | randomized | matched |
| real GPU | real | real | real | real |
| Browserbase | real (their fleet) | real | real | real |
The progression is from easily-detected (naive) to perfect (real GPU). Most teams land at patchright + per-context noise as the cost-effective sweet spot. Move to real-GPU services when targets get sophisticated.
For wider browser-driving patterns, see Stagehand vs Playwright for AI-driven scraping.
Verifying your WebGL fingerprint
Public verification sites:
| site | shows | format |
|---|---|---|
| browserleaks.com/webgl | full WebGL parameter dump + render hash | HTML |
| webglreport.com | extension list, capabilities | HTML |
| amiunique.org | combined fingerprint including WebGL | HTML report |
| fingerprint.com/demo | enterprise-grade fingerprint | JSON |
Run your scraper against browserleaks.com/webgl and check three things:
- UNMASKED_VENDOR and UNMASKED_RENDERER match a real desktop GPU, not “Google” or “SwiftShader”
- Extension list is consistent with the claimed GPU
- Render hash differs across contexts but is stable within one context
from patchright.async_api import async_playwright
async def webgl_check():
async with async_playwright() as p:
for run in range(3):
browser = await p.chromium.launch(headless=True)
ctx = await browser.new_context()
page = await ctx.new_page()
await page.goto("https://browserleaks.com/webgl")
renderer = await page.text_content("td:has-text('Unmasked Renderer') + td")
vendor = await page.text_content("td:has-text('Unmasked Vendor') + td")
print(f"Run {run + 1}: vendor={vendor}, renderer={renderer}")
await browser.close()
Bot-detection vendors keep deny lists of common headless renderer strings. “Google Inc. (Google), ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device…” is on every list. If your output contains that string, you are getting blocked.
Common failure modes
- Inconsistent capability vs vendor claim: claiming an Intel UHD 630 but reporting MAX_TEXTURE_SIZE 8192 (which is below what UHD 630 supports). Fingerprinters cross-check.
- Mismatched extension list: the WebGL extension list (
gl.getSupportedExtensions()) varies by GPU. A spoofed Intel claim with an NVIDIA-only extension is a flag. - WebGL2 mismatch: WebGL1 (
WebGLRenderingContext) and WebGL2 (WebGL2RenderingContext) are separate prototypes. Patching one and not the other is a flag. - OffscreenCanvas WebGL: separate context type, also needs patches. patchright handles this.
- Service workers: a service worker can independently query WebGL parameters and report differently than the main page. Less common in 2026 but still appears in some fingerprinting libraries.
For a complete view of headless detection patterns including WebGL, see the BotD repo on GitHub which documents how Fingerprint Pro detects automated browsers.
Operational checklist
- Use patchright or rebrowser-playwright as your default Chromium driver
- Verify against browserleaks.com/webgl in your CI
- Pick a realistic GPU profile (Intel UHD 630, Apple M1, NVIDIA GTX 1660) and stick with it per scraping job
- Rotate the GPU profile across jobs but keep it stable within a session
- Pair WebGL spoofing with canvas, audio, and behavioral defenses
- Watch for new WebGL extensions in browser updates (Chrome adds 1-2 per major version)
- Log the WebGL fingerprint per request for drift detection
- Use clean residential or mobile proxies; perfect WebGL on a flagged datacenter IP still gets blocked
For the canvas counterpart, see canvas fingerprinting bypass techniques.
WebGPU: the next surface
WebGPU shipped in Chrome 113 in 2023 and is increasingly available across browsers. It is a more modern GPU API that exposes a different set of parameters and capabilities. Fingerprinters started incorporating WebGPU into their checks in 2024.
The 2026 state:
- Chrome and Edge have full WebGPU support
- Firefox has partial support behind a flag
- Safari shipped WebGPU in version 18
- Most fingerprinting vendors check WebGPU parameters alongside WebGL
The same principles apply: spoof GPUAdapter.info.vendor and GPUAdapter.info.architecture, override GPUDevice.limits to consistent values, and add render noise. patchright is starting to ship WebGPU spoofing in 2026 versions.
If your target is sophisticated enough to fingerprint WebGPU, expect the cat-and-mouse game to accelerate through 2027. The same techniques that work for WebGL apply, but the parameter set is different and the API is more complex.
What about hardware concurrency and other related signals
WebGL fingerprinting often combines with related signals:
navigator.hardwareConcurrency(CPU cores)navigator.deviceMemory(RAM in GB)screen.width,screen.height,screen.colorDepthwindow.devicePixelRatio
These are easy to spoof but easy to mismatch. Claiming an Intel UHD 630 GPU on a system with 1 CPU core and 4 GB RAM is implausible. Pick a coherent device profile (real laptop spec) and override all these values consistently.
Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 8 });
Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 });
Object.defineProperty(screen, 'colorDepth', { get: () => 24 });
Do this in add_init_script before the page loads. Each Object.defineProperty needs to use a getter to survive JSON serialization checks.
FAQ
Q: do I have to spoof every WebGL parameter or just vendor and renderer?
At minimum vendor and renderer. For sophisticated targets, spoof the full set including capabilities and extensions. Use a real device’s WebGL fingerprint as your reference and copy every value.
Q: can I run Chrome with –disable-webgl to skip the check entirely?
You can, but no real browser disables WebGL anymore. A WebGL-disabled browser in 2026 is itself a strong bot signal. Better to spoof correctly than to disable.
Q: will hardware acceleration in headless mode help?
Yes. Run with --use-gl=desktop and --enable-gpu on a machine with a real GPU and your WebGL fingerprint becomes that real GPU. Cloud machines without GPUs cannot do this and are forced into SwiftShader.
Q: do mobile browsers have WebGL fingerprinting?
Yes. Safari iOS exposes Apple GPUs (Apple A15, M1) and Chrome Android exposes Mali, Adreno, or PowerVR. The fingerprints are distinct from desktop and used to validate “this device claims to be mobile, does its WebGL match?”
Q: how often do real GPU fingerprints change?
GPU driver updates are the main source. Windows updates, NVIDIA/AMD driver releases, and Chrome updates that change ANGLE behavior all shift fingerprints. Real users see drift every few months. Plan to refresh your spoofed profiles quarterly to match current real-world distributions.
Common pitfalls in production WebGL spoofing
The first failure mode is shader compilation timing leaks. Real GPUs compile WebGL shaders in microseconds (5-50us for trivial shaders, 200-800us for complex ones). SwiftShader running in a Docker container takes 8-15ms to compile the same shader because it has to JIT the GLSL into CPU instructions. Fingerprinters time gl.compileShader() and flag any client whose compilation latency falls outside the GPU range. Even with perfect parameter spoofing, the timing leak gives you away. The mitigation is to monkey-patch gl.compileShader to delay-then-respond if compilation completes too quickly to look like a GPU, or too slowly to look like a real one. The patch needs to know the timing distribution of the GPU you are claiming to have.
The second pitfall is the WEBGL_debug_renderer_info extension. Chrome 113+ deprecated this extension’s exposure to non-WebGL2 contexts under certain feature flags, and the rollout differs by region and Chrome channel. A spoof that returns UNMASKED_VENDOR_WEBGL via the deprecated extension on a Chrome version where the extension is gated behind a flag is anomalous. Real Chrome 124 still exposes the extension by default, but a Chrome 126+ stable on certain enterprise policies returns null. If you spoof a Chrome 126 user-agent but return populated UNMASKED values when the real browser would have returned null, that is a flag. Pin your spoof profile’s Chrome version exactly and verify the extension exposure matches.
The third pitfall is precision format mismatches. gl.getShaderPrecisionFormat() returns the precision range and precision bits for vertex and fragment shaders. Real GPUs return characteristic values: NVIDIA returns rangeMin=127 rangeMax=127 precision=23 for HIGH_FLOAT, Intel UHD returns 127/127/23 too, but PowerVR mobile returns 62/62/16. If you spoof an Intel UHD vendor string but return PowerVR precision values because your patch only covers getParameter, the cross-check fails. Patch getShaderPrecisionFormat to return values consistent with your claimed GPU profile.
Real-world example: surviving Akamai WebGL probes
A scraper running 30 Playwright workers against an Akamai-protected airline booking site started seeing “Access Denied 403” errors within 5 seconds of every page load. TLS was correct, HTTP/2 was correct, canvas had per-context noise. The blocker turned out to be Akamai’s WebGL probe at /_bm/get_params which queried 47 distinct WebGL parameters in sequence and computed a SHA-256 over the concatenated values. The patchright default profile only covered 12 of those 47 parameters, leaving 35 returning real SwiftShader values that exposed the headless container.
The fix was to capture a complete reference profile from a real Intel UHD 630 desktop, dump all 47 parameter values, and bake them into a custom init script:
import json
import hashlib
# Captured from a real Intel UHD 630 Windows 10 Chrome 124 desktop
INTEL_UHD_630_FULL = json.load(open("intel_uhd_630_reference.json"))
def make_complete_webgl_init(profile: dict, seed: int) -> str:
param_entries = ",".join(
f"{k}: {json.dumps(v)}" for k, v in profile["parameters"].items()
)
return f"""
(() => {{
const PARAM_MAP = {{ {param_entries} }};
const wrap = (proto) => {{
const orig = proto.getParameter;
proto.getParameter = function(p) {{
if (PARAM_MAP[p] !== undefined) return PARAM_MAP[p];
return orig.apply(this, arguments);
}};
const origExt = proto.getSupportedExtensions;
proto.getSupportedExtensions = function() {{
return {json.dumps(profile["extensions"])};
}};
}};
if (window.WebGLRenderingContext) wrap(WebGLRenderingContext.prototype);
if (window.WebGL2RenderingContext) wrap(WebGL2RenderingContext.prototype);
}})();
"""
# Inject before each new context
init_script = make_complete_webgl_init(INTEL_UHD_630_FULL, seed=worker_seed)
ctx = await browser.new_context()
await ctx.add_init_script(init_script)
After deployment the 403 rate dropped from 100 percent to 6 percent within 90 minutes. The lesson is that WebGL fingerprint coverage matters more than the cleverness of the noise: every parameter the target queries must return a coherent value, and “coherent” is defined by a real reference device.
Wrapping up
WebGL fingerprinting is the meatier sibling of canvas fingerprinting and catches scrapers that handle TLS but neglect the GPU side. patchright + a realistic device profile + clean residential proxies covers most cases in 2026. For high-stakes targets, real-GPU runtimes via Browserbase or similar services eliminate the question. Pair this guide with our canvas fingerprinting bypass and audio fingerprinting in browsers writeups for the full client-side picture, and browse the anti-detect-browsers category on DRT for related deep-dives.