Spoofing a GPU used to mean spoofing one interface: the two WEBGL_debug_renderer_info strings. That era is over. WebGPU ships a second, structured view of the same silicon through navigator.gpu, and it did not inherit anyone’s spoofing patches. For a couple of years the result was a clean contradiction. WebGL said one GPU, adapter.info said another, and a single cross-interface check tore the persona apart.
Two interfaces, one piece of hardware
A GPU is not a fingerprint dimension. It is a physical device that surfaces through many interfaces, each independently queryable from JavaScript:
- WebGL unmasked vendor/renderer strings (
UNMASKED_VENDOR_WEBGL=37445,UNMASKED_RENDERER_WEBGL=37446) - WebGL numeric parameters (
MAX_TEXTURE_SIZE,MAX_VIEWPORT_DIMS,ALIASED_*_RANGE,getShaderPrecisionFormat) - WebGL real rendering:
shaderSource→compileShader→drawArrays→readPixels - WebGPU
GPUAdapter.info(vendor,architecture,device,description) - WebGPU
GPUAdapter.limits(dozens of numeric caps) andfeatures - Canvas 2D rendering behavior and copies of all of the above inside a Web Worker / OffscreenCanvas
The detector’s leverage is simple. All of these read the same hardware, so they must tell one story. Spoof any subset, leave the rest genuine, and the gap between them is the tell. WebGL and WebGPU are the two richest views, which makes their agreement (and their agreement with the claimed OS platform) the highest-value consistency assertion in the modern GPU stack. Call it the triad: WebGL GPU ↔ WebGPU adapter.info ↔ claimed OS/UA platform.
The bug: WebGL spoofed, WebGPU genuine
WebGPU is younger than WebGL by roughly a decade of browser-fingerprinting history. When anti-detect vendors built their WebGL spoofing, navigator.gpu either did not exist or was behind a flag, so the spoofing layer never touched it. When Chrome shipped WebGPU to stable and detectors started reading adapter.info, the omission surfaced as a contradiction.
The mismatch reproduces on a live spoofing build. With a WebGL renderer set to Intel UHD 620 (a gen-9.5 part), navigator.gpu.adapter.info.architecture still reported the real machine’s GPU:
a browser can nail WebGL and still leak
gen-12lpthrough navigator.gpu. The newest interface is where the oldest spoofs go to die.from engine-level anti-detect testing, 2026
gen-12lp is Intel Xe (Tiger Lake era). gen-9.5 is UHD 620 (Kaby Lake / Coffee Lake era). Both are Intel, so the vendor family matched. The architecture generations were years apart and internally inconsistent. A detector reading both interfaces sees a machine claiming to be a 2017 iGPU on the WebGL side and a 2020 iGPU on the WebGPU side. That is not noise. It is a deterministic, reproducible mismatch, and it costs nothing to check. This dimension does not even appear in older fingerprint backlogs. It emerged as a fresh gap the moment detectors added WebGPU probes.
What the detectors actually read
WebGPU probing is now routine. Not exotic. From a July 2026 API-access sweep across 32 fingerprinting and anti-bot sites, WebGPU showed up on CreepJS, Iphey, BrowserScan, the Vastel-family sites (deviceandbrowserinfo, fingerprint-scan.com), FingerprintJS Pro (demo.fingerprint.com) and Cloudflare Turnstile.
| Detector | WebGPU probe observed | Notes |
|---|---|---|
| Iphey | GPUAdapter.limits read item-by-item (~×71) + adapter.info |
single-file index.js |
| Vastel family | adapter.info + limits enumeration (~×71) |
cstlxp.js / device_info.min.js |
| FingerprintJS Pro | limits ×71 + adapter.info |
first-party-proxied agent |
| CreepJS | adapter.info + hash; cross-context (main/Worker/WebGL) |
lie/consistency engine |
| BrowserScan | adapter.info + GPUDevice.createTexture (~×285) + real compute |
does not just read strings |
FingerprintJS-family collectors read limits one property at a time: maxTextureDimension1D/2D/3D, maxComputeWorkgroupSizeX/Y/Z, maxBufferSize, maxBindGroups and so on, roughly 71 distinct reads. BrowserScan goes further than strings. It calls GPUDevice.createTexture hundreds of times and runs actual compute work, so a spoof that fabricates adapter.info while leaving the device unable to back the claim risks a different class of contradiction.
Here is the surface, reproduced minimally:
// The oracle: request an adapter, read its structured identity + caps.
const adapter = await navigator.gpu.requestAdapter({
powerPreference: "high-performance",
});
if (!adapter) throw new Error("WebGPU unavailable");
// adapter.info: the second GPU identity (W3C: GPUAdapterInfo)
const { vendor, architecture, device, description } = adapter.info;
console.log({ vendor, architecture, device, description });
// genuine Chrome on Intel Xe → { vendor: "intel", architecture: "gen-12lp", ... }
// spoofed persona must instead read Intel UHD 620 → gen-9(.5)
// Enumerate limits item-by-item, exactly how Iphey / Vastel / FPJS Pro do it.
const limits = {};
for (const key in adapter.limits) {
limits[key] = adapter.limits[key]; // getter per property, ~71 reads
}
console.log(limits.maxTextureDimension2D, limits.maxComputeWorkgroupSizeX,
limits.maxBufferSize, limits.maxBindGroups);
// features - a set of capability flags
console.log([...adapter.features]);
And the behavioral escalation BrowserScan uses. This one exercises the device, not its metadata:
const device = await adapter.requestDevice();
const tex = device.createTexture({ // called ~×285 in the wild
size: [256, 256],
format: "rgba8unorm",
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
});
// followed by real compute-pipeline dispatch + readback → a rendering-behavior hash
The fix: one persona, both interfaces
The correct fix is engine-level and config-driven, the same principle that governs every other dimension: all interfaces derive from one persona. The launcher already picks the WebGL GPU (Intel UHD 620) from the persona. The WebGPU identity has to come from that same choice, not leak the host.
Concretely, override adapter.info.{vendor,architecture} at the Blink layer from the persona config’s webgpu.* block, so navigator.gpu reports intel / gen-9 to match the spoofed WebGL UHD 620. Three nuances separate a believable override from a fresh tell:
-
A non-empty
vendoris the master switch. If the persona sets a vendor, the override engages. Leave it empty and the real values pass through. This keeps the patch inert when unconfigured (the framework’s baseline rule: no switch, no change). -
An empty
architecturestring is meaningful. You must be able to clear it, not only set it. Real NVIDIA and AMD adapters expose an emptyarchitecturein Chrome. A persona for an NVIDIA or AMD GPU therefore has to emit an empty architecture, which means the override must support clearing the field, not merely writing agen-*value. A naive implementation that can only set a string will produce an Intel-stylegen-*architecture on an NVIDIA persona, an instant contradiction. -
Intel with an empty architecture is itself a tell. Real Intel adapters do expose a
gen-*architecture. So “Intel WebGL + empty WebGPU architecture” is a mismatch the scanner warns on specifically. The persona must fill the Intel case and clear the NVIDIA/AMD case, asymmetrically, per vendor.
What you should not do is fabricate limits and features. Those must stay at a real capability tier for the claimed hardware. Inventing them creates a new layer of contradictions (a gen-9 Intel claiming maxBufferSize values no real UHD 620 reports, or a texture-dimension cap that no Chrome build on that GPU exposes). Keeping limits genuine is logged as residual risk rather than treated as solved, an honest tradeoff rather than a claim of completeness. BrowserScan’s real createTexture/compute path is the reason: it can probe whether the device backs the metadata.
How the scanner scores it
Our scanner encodes the triad as three distinct checks. Paraphrasing the logic:
- Cross-interface GPU mismatch (heavy deduction). Extract the coarse vendor family from the WebGL unmasked renderer, compare it to
adapter.info.vendor. If WebGL saysinteland WebGPU saysnvidia(or vice versa), that is a hard fail, the single most damaging GPU signal, weighted far above the cosmetic checks. - Intel WebGL but empty WebGPU architecture → warn. Same vendor, but the missing
gen-*string betrays a spoof that set the vendor and forgot the architecture. - WebGPU supported but
adapter.infovendor/architecture empty → warn. Genuine Chrome always populates these. An emptyadapter.infoon a WebGPU-capable browser is a VMLogin-style tell. The spoofing layer left the field blank rather than filling it from a persona.
// scanner, abridged - WebGL family vs WebGPU vendor
if (wgFam && gpu.vendor && wgFam !== gpu.vendor) {
fail("gpu-triad", "Cross-interface GPU mismatch", 22);
} else if (wgFam === "intel" && gpu.vendor === "intel" && !gpu.architecture) {
warn("gpu-triad", "Intel WebGL but empty WebGPU architecture", 8);
}
// separately: WebGPU supported but adapter.info empty → warn
if (gpu.supported && !gpu.vendor) {
warn("webgpu-empty", "adapter.info vendor/architecture empty", 10);
}
The mismatch check deliberately compares vendor families, not exact strings. Intel(R) UHD Graphics 620 and intel should agree, and requiring byte equality across two interfaces that format their strings differently would produce false positives. The gen-12lp vs gen-9.5 case that started this post surfaces once both interfaces report Intel: the contradiction lives in the architecture generation, not in any string diff.
Validation: seven live sites
After the fix, we re-ran the persona (a self-consistent Intel UHD 620 build, webgpu = intel/gen-9) against seven live detection sites, capturing verdict text, full-page screenshots and cross-interface probes.
The deterministic assertion held on all seven. navigator.gpu.adapter.info read intel / gen-9 on every site, matching the WebGL unmasked Intel UHD 620. On CreepJS specifically, the GPU showed as HIGH-confidence consistent across three contexts (main thread, Web Worker and WebGL) with 0% headless and 0% stealth, and navigator.gpu produced a hash without being flagged as a lie. Worker UA matched the main-thread UA. navigator.webdriver was false.
The residual deductions that remained (BrowserScan’s canvas-tampering and anti-detect flags, a timezone mismatch from a test-harness artifact) were pre-existing tradeoffs unrelated to WebGPU. The fix changed only navigator.gpu, and it introduced no new regression on any site. That is the honest read: the triad was closed, not the entire fingerprint made invisible.
The principle generalizes
The triad is one instance of a broader rule. A GPU speaks through WebGL strings, WebGL parameters, the WebGPU adapter, WebGPU limits, canvas and rendering behavior, and copies of all of it inside Workers and OffscreenCanvas. Every one of those is an independent oracle for the same hardware, and a detector’s cheapest, most reliable move is to read two of them and check that they agree, rather than reverse-engineer any single value.
That is why single-interface spoofing keeps losing. Each new API Chrome ships is another view of a machine your persona has to keep telling one story about. WebGPU was the first new oracle in years, and it caught the spoofs that had gone unquestioned because nothing had asked the same question twice.
Takeaway
If you spoof a GPU, spoof every interface that reveals it, from one persona: WebGL strings and parameters, adapter.info vendor and architecture (set for Intel, cleared for NVIDIA/AMD), limits and features held at a genuine tier. Then read your own navigator.gpu on CreepJS and BrowserScan and confirm it agrees with WebGL and the UA platform. The newest interface is where the oldest spoofs go to die. Check it before a detector does.