Almost every anti-detect walkthrough tells you to spoof UNMASKED_RENDERER_WEBGL, then declares the WebGL problem solved. The string is real and detectors do read it. It is maybe five percent of the surface. The rest is dozens of numeric getParameter values, a full sweep of shader precision formats, the extension lists, and the thing that kills string-only spoofs: a real rendered frame read back pixel by pixel. Getting past a serious WebGL probe takes two things, and most tools do only the first.
What the detectors actually read
We ran a read-only API-access recon pass against 32 fingerprinting and anti-bot sites in July 2026, logging every WebGL call at the engine level with the GLenum arguments decoded. The picture is consistent. WebGL is one of the heaviest and most universal fingerprinting dimensions in current use. On CreepJS it was the second-busiest surface after canvas (189 WebGL accesses in a single run). BrowserScan, which ships FingerprintJS V5, fired nearly 5,000 WebGL accesses. Four things get read, in rough order of how many tools bother:
1. The debug-renderer strings. UNMASKED_VENDOR_WEBGL (GLenum 37445) and UNMASKED_RENDERER_WEBGL (37446), reached through the WEBGL_debug_renderer_info extension. Sannysoft, Whoer, the Vastel-family scanners, DataDome, CreepJS: everyone reads these. This is the part everyone knows about, and the shallowest.
2. The full numeric parameter enumeration. Dozens of getParameter(GLenum) calls covering capability limits: MAX_TEXTURE_SIZE, MAX_VIEWPORT_DIMS, ALIASED_LINE_WIDTH_RANGE, ALIASED_POINT_SIZE_RANGE, MAX_VERTEX_UNIFORM_VECTORS, MAX_FRAGMENT_UNIFORM_VECTORS, MAX_VARYING_VECTORS, MAX_COMBINED_TEXTURE_IMAGE_UNITS and more. These are the numeric fingerprint of a specific GPU + driver. On top of that, getShaderPrecisionFormat is called across the full matrix of shader type × precision × numeric type. AmIUnique fired it roughly 144 times, Iphey around 24. And getSupportedExtensions / getExtension enumerate the driver’s extension list.
3. A real rendered frame. The serious detectors do not stop at reading values. They compile a shader, draw and read the pixels back: shaderSource → compileShader → attachShader → drawArrays → readPixels, then hash the result. CreepJS, the Vastel-family scanners, Incolumitas and Cloudflare Turnstile all do this. Turnstile even exercises renderbuffers and getInternalformatParameter. A string swap with no coherent render behind it is an incomplete lie, and this is where it shows.
4. WebGL 2 and cross-interface agreement. The same GPU identity now has to agree with navigator.gpu adapter info (WebGPU is a separate post) and be plausible for the claimed OS and UA platform. Contradict any of these and the whole persona is suspect.
Here is the shape of the enumeration and the GLenum constants a scanner walks:
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
const dbg = gl.getExtension('WEBGL_debug_renderer_info');
// The strings everyone spoofs - GLenum values are stable across browsers.
const UNMASKED_VENDOR_WEBGL = 0x9245; // 37445
const UNMASKED_RENDERER_WEBGL = 0x9246; // 37446
const vendor = gl.getParameter(dbg.UNMASKED_VENDOR_WEBGL); // "Google Inc. (Intel)"
const renderer = gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL); // "ANGLE (Intel, Intel(R) UHD Graphics 620 ...)"
// The numeric capability set - this is the part string-only spoofs leave real.
const params = {
MAX_TEXTURE_SIZE: gl.getParameter(gl.MAX_TEXTURE_SIZE), // 0x0D33
MAX_VIEWPORT_DIMS: gl.getParameter(gl.MAX_VIEWPORT_DIMS), // 0x0D3A -> [w, h]
MAX_RENDERBUFFER_SIZE: gl.getParameter(gl.MAX_RENDERBUFFER_SIZE), // 0x84E8
ALIASED_LINE_WIDTH_RANGE: gl.getParameter(gl.ALIASED_LINE_WIDTH_RANGE), // 0x846E -> [lo, hi]
ALIASED_POINT_SIZE_RANGE: gl.getParameter(gl.ALIASED_POINT_SIZE_RANGE), // 0x846D -> [lo, hi]
MAX_VERTEX_UNIFORM_VECTORS: gl.getParameter(gl.MAX_VERTEX_UNIFORM_VECTORS), // 0x8DFB
MAX_FRAGMENT_UNIFORM_VECTORS: gl.getParameter(gl.MAX_FRAGMENT_UNIFORM_VECTORS), // 0x8DFD
MAX_VARYING_VECTORS: gl.getParameter(gl.MAX_VARYING_VECTORS), // 0x8DFC
MAX_COMBINED_TEXTURE_IMAGE_UNITS: gl.getParameter(gl.MAX_COMBINED_TEXTURE_IMAGE_UNITS), // 0x8B4D
};
// Shader precision - walked across the full matrix (vertex/fragment × LOW/MEDIUM/HIGH × float/int).
const precision = {};
for (const st of ['VERTEX_SHADER', 'FRAGMENT_SHADER']) {
for (const p of ['LOW_FLOAT','MEDIUM_FLOAT','HIGH_FLOAT','LOW_INT','MEDIUM_INT','HIGH_INT']) {
const f = gl.getShaderPrecisionFormat(gl[st], gl[p]);
precision[`${st}.${p}`] = [f.rangeMin, f.rangeMax, f.precision];
}
}
const extensions = gl.getSupportedExtensions(); // full driver extension list
The parameter families a real scanner groups and hashes look like this:
| Family | Example GLenums | What it pins down |
|---|---|---|
| Debug renderer strings | 37445 VENDOR, 37446 RENDERER |
GPU + ANGLE backend text (the shallow part) |
| Texture / viewport limits | 0x0D33 MAX_TEXTURE_SIZE, 0x0D3A MAX_VIEWPORT_DIMS, 0x84E8 MAX_RENDERBUFFER_SIZE |
GPU generation, the single highest-signal number |
| Rasterizer ranges | 0x846E LINE_WIDTH_RANGE, 0x846D POINT_SIZE_RANGE |
driver rasterizer behavior |
| Shader capacity | 0x8DFB / 0x8DFD uniform vectors, 0x8DFC varying vectors |
shader compiler limits |
| Shader precision | getShaderPrecisionFormat × 12 |
float/int ranges per precision tier |
| Extensions | getSupportedExtensions() |
driver feature set |
| Rendered pixels | readPixels after drawArrays |
actual rasterization output hash |
On the render side, the detector compares a hash of the pixels your GPU + driver + ANGLE stack actually produced:
// Detector sketch: draw a known scene, hash the readback.
gl.drawArrays(gl.TRIANGLES, 0, vertexCount);
const px = new Uint8Array(w * h * 4);
gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, px);
const renderHash = sha256(px); // grouped with the renderer string + param set
Say your renderer string claims “NVIDIA RTX 3070 Ti” but the pixels were rasterized by a spoofed persona over a real Intel iGPU (or by SwiftShader). The render hash and the string then tell different stories, and a string-only swap has no way out of it.
Rule one: spoof the whole set, coherently
The recon data hands us a clean natural experiment. In controlled anti-detect browser testing across four products against CreepJS over the same residential proxy, two of them (VMLogin and AdsPower) earned CreepJS high confidence on both the WebGL and Worker fingerprints. The other two, bablo and CloakBrowser, landed at moderate. The difference was not subtlety. It was completeness.
The two winners on WebGL confidence reproduced the entire parameter set for a real GPU, down to
maxTextureSize, the viewport dims, the extension list and the precision formats, while the losers changed the renderer string and left the numbers real. One of the winners, though, picked a 2008 integrated chip. That is the whole lesson: self-consistency and credibility are different problems.controlled anti-detect browser testing, July 2026
AdsPower reproduced the full parameter profile of an Intel HD 510 and CreepJS trusted it completely. VMLogin did the same for its claimed GPU, matching maxTextureSize=4096, the viewport dims, extensions and precision to that specific part. CloakBrowser, a source-level Chromium fork running the newest engine of the four, only got the string coherent and stayed at moderate. So did bablo. The operational takeaway maps straight to our own DO/DON’T list: don’t change only the string. A half-spoofed WebGL surface (real numbers under a fake renderer name) is internally contradictory, and any scanner that groups the parameter set will see the seam.
Rule two: pick a believable GPU, not just a consistent one
Here is the trap. VMLogin got high confidence, and it did so by faithfully reproducing an Intel Q45/Q43 Express, a 2008 integrated GPU, with MAX_TEXTURE_SIZE=4096. Perfectly self-consistent. Also perfectly absurd: a 2008 chip on a browser claiming to be Chrome 139. bablo made the milder version of the same mistake with a 2012 AMD Radeon HD 7700.
Modern GPUs report MAX_TEXTURE_SIZE of at least 16384, often with a 16384 or 32767 viewport. A MAX_TEXTURE_SIZE of 4096 in 2026 is a klaxon. CreepJS did not flag it, because CreepJS checks internal consistency and the persona was consistent. A scanner tuned for realism absolutely will. “Old hardware on a current browser” is a rare, tightly clustered combination with a tiny anonymity set, and rarity is exposure.
| Product | Claimed GPU | MAX_TEXTURE_SIZE | CreepJS WebGL/Worker confidence |
|---|---|---|---|
| bablo | AMD Radeon HD 7700 (2012) | 16384 | moderate |
| AdsPower | Intel HD 510 | 16384 | high |
| CloakBrowser | NVIDIA RTX 3070 Ti | 16384 | moderate |
| VMLogin | Intel Q45/Q43 Express (2008) | 4096 | high |
Read the diagonal. The two high scores are AdsPower (modern-ish iGPU, full parameter set) and VMLogin (ancient iGPU, full parameter set). Confidence tracks completeness, not credibility. CloakBrowser picked a genuinely modern card (RTX 3070 Ti) but only got moderate because it did not reproduce the whole set. The ideal is the combination nobody in that sample hit cleanly: a common, modern GPU and its full, coherent parameter profile.
That is why our scanner treats GPU age as an independent signal rather than trusting internal consistency alone. The realism check flags a low MAX_TEXTURE_SIZE or an ancient renderer string even when everything else agrees:
const ancient = /(q45|q43|g41|g45|gma|hd graphics 3000|radeon hd \d{4}|...)/.test(rend);
if ((typeof maxTex === 'number' && maxTex > 0 && maxTex < 8192) || ancient)
add('gpu-age', 'GPU realism', 'warn',
`GPU is obsolete for a modern browser - MAX_TEXTURE_SIZE ${maxTex}. ` +
`Modern GPUs report ≥16384; an ancient GPU on current Chrome is ` +
`self-consistent but a rare, high-signal combination.`, 10);
The threshold is deliberately < 8192 rather than < 16384. Some legitimately current integrated and mobile parts sit in the 8192 band, and we would rather warn on the genuinely obsolete than false-positive on a plausible low-end laptop. It is a heuristic and we hedge it accordingly. But 4096 is not a close call.
The readback noise trap
Add per-profile noise to readPixels to defeat cross-profile correlation (the same anti-correlation move used on canvas) and it has to obey the two rules canvas noise does, plus one that is specific to rendered frames.
First, determinism: the noise must be a pure function of (seed, input). The same profile reading the same rendered scene twice must return byte-identical pixels. Per-call randomization is the single most reliable lie a consistency detector catches. CreepJS’s lie test is literally “call the same operation twice and demand equality,” and a source-level fork in our testing got flagged on exactly this after adding per-call jitter.
Second, the WebGL-specific one: perturb only real textured regions, never uniform or solid buffers. A remembered failure: readback noise disturbed a uniform buffer and the detector caught it because the “blank should stay blank” invariant broke. If a scene renders a solid color or a cleared region, those pixels must come back exactly as drawn. Noise belongs only on genuinely textured, non-uniform output. Same rule as canvas, and it is not optional.
Takeaway
A WebGL spoof that only rewrites UNMASKED_RENDERER is a facade with real numbers behind it, and any scanner that groups the parameter set sees straight through it. Reproduce the whole profile coherently (the numeric limits, the viewport dims, the twelve precision formats, the extension list and a render that hashes consistently) and make it agree with WebGPU and the claimed platform. Then, separately, choose a GPU that a real 2026 machine would actually have: common, modern, MAX_TEXTURE_SIZE ≥ 16384. Self-consistency gets you high confidence on a lie detector. Believability is what gets you past a scanner that knows a 2008 chip has no business running Chrome 139. You need both, and they are different problems. Snapshot: 2026-07; detectors move, so re-verify.