liarjs.dev
← Field notes
WebGPU

WebGPU, the new GPU oracle: adapter.info and the cross-interface triad

August 19, 2026 · 9 min read

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.

The cross-interface GPU triad: WebGL, WebGPU adapter.info and the UA platform must agree

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) and features
  • 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-12lp through 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:

  1. A non-empty vendor is 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).

  2. An empty architecture string is meaningful. You must be able to clear it, not only set it. Real NVIDIA and AMD adapters expose an empty architecture in 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 a gen-* value. A naive implementation that can only set a string will produce an Intel-style gen-* architecture on an NVIDIA persona, an instant contradiction.

  3. 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 says intel and WebGPU says nvidia (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.info vendor/architecture empty → warn. Genuine Chrome always populates these. An empty adapter.info on 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.

Sources & further reading
  1. BrowserScan
  2. WebGPU adapter info, W3C spec
  3. CreepJS (Abraham Juliot)
More field notes
← Back to field notes