WebGL Fingerprinting
getParameter looks like it asks your graphics card a question. Many answers are decided in software: some queried then thrown away, one never asked at all.
Published · Updated
- Category
- Rendering
- API
WebGLRenderingContext.getParameter- Reveals
- graphics API, GPU model, driver
- Spoofing
- Spoofable, spoof detectable
- Since
- 2011
getParameter reads like a hardware query. You ask for MAX_VERTEX_ATTRIBS
and get 16; you ask for SUBPIXEL_BITS and get 4. It is natural to assume
those numbers came from the graphics card.
Some did. Several did not, and the ones that did not are more interesting, because the reasons they hold the values they hold have nothing to do with your hardware. One of them is a workaround for a slow test suite that nobody has gotten around to removing.
The panel above shows your own set, with the graphics API underneath named.
Your WebGL fingerprint is not talking to your GPU
On every major desktop platform, Chromium does not hand WebGL to the system's OpenGL driver. It hands it to ANGLE — Almost Native Graphics Layer Engine — which re-implements OpenGL ES on top of whatever the platform actually prefers: Direct3D 11 on Windows, Metal on macOS, Vulkan or native GL elsewhere.
You do not have to take that on faith. WebGL ships a debug extension that hands your shader back after translation, and the output names the target language outright:
const gl = document.createElement("canvas").getContext("webgl2");
const ext = gl.getExtension("WEBGL_debug_shaders");
const sh = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(sh, `#version 300 es
precision mediump float;
uniform float uT;
out vec4 c;
void main(){ c = vec4(sin(uT), 0.5, 1.0, 1.0); }`);
gl.compileShader(sh);
console.log(ext.getTranslatedShaderSource(sh));On this machine — macOS, Apple M2 Max — that returns Metal Shading Language, opening with the giveaway line:
#include <metal_stdlib>
#define ANGLE_ALWAYS_INLINE __attribute__((always_inline))Six kilobytes of it, for a four-line shader. The same GLSL on a Windows machine
comes back as HLSL. The renderer string names the same thing more directly —
mine reads ANGLE (Apple, ANGLE Metal Renderer: Apple M2 Max, Unspecified Version) — but a string is text a browser can be told to print, whereas the
translated shader is evidence of work actually performed.
Once you know a translation layer is in the middle, the limits stop looking like hardware readings and start looking like what they are: whatever that layer decided to report.
The one that is never measured
SUBPIXEL_BITS reports how many bits of sub-pixel precision the rasterizer
uses when snapping geometry to the pixel grid. It is a genuine hardware
characteristic and real GPUs differ.
ANGLE declares it as a field initializer:
GLuint subPixelBits = 4;Across ANGLE's entire source tree there are exactly three places that touch
that field: the declaration above, the line in Context.cpp that hands it to
your getParameter call, and one backend that overwrites it —
mNativeCaps.subPixelBits = limitsVk.subPixelPrecisionBits;That is the Vulkan backend, and it is a real device query. Every other backend
— Direct3D 11, Metal, OpenGL, WebGPU — never assigns the field at all, so it
keeps the literal 4. It reads 4 here, on Metal, as expected.
So a value that would be informative if it were measured is uninformative on most configurations because it is declared. But the honest version of that sentence has an exception in it: on a Vulkan-backed browser, typically Android or Linux, you may well see 8, and that 8 is a real reading of real silicon. Any article telling you this parameter is hardcoded "regardless of backend" has not checked the Vulkan path.
The one that is measured and then discarded
MAX_VERTEX_ATTRIBS is the cleaner illustration, because here the device is
asked and the answer is thrown away.
Every backend queries it — the Vulkan path reads
limitsVk.maxVertexInputAttributes, the D3D11 path derives it from
D3D11_STANDARD_VERTEX_ELEMENT_COUNT (32), the GL path calls
glGetIntegerv(GL_MAX_VERTEX_ATTRIBS). Then, in a backend-independent pass,
ANGLE clamps every capability against constants in its own header:
ANGLE_LIMIT_CAP(caps->maxVertexAttributes, MAX_VERTEX_ATTRIBS);where MAX_VERTEX_ATTRIBS = 16. Because Vulkan's own specification requires
every conformant device to support at least 16 vertex input attributes, the
clamp provably always binds: the device's answer is guaranteed to be discarded
in favour of the constant. Everyone reports 16, forever, regardless of what
their GPU can do.
This is the accurate general shape, and it is worth stating precisely because
the sloppy version — "ANGLE hardcodes these" — is wrong. Most of these
parameters are queried. They are then capped by a header constant that
usually wins. A few escape the clamp entirely and carry real hardware
information: MAX_TEXTURE_LOD_BIAS is an unclamped passthrough of the device's
own float on both the Vulkan and GL backends, and MAX_SAMPLES is genuinely
negotiated with the device on every real backend.
The famous asymmetry, and why the usual explanation is wrong
The best-known oddity in this surface is that on Chrome's default Windows
backend, MAX_VERTEX_UNIFORM_VECTORS reports 4096 while
MAX_FRAGMENT_UNIFORM_VECTORS reports 1024. A 4:1 split between the vertex
and fragment stages looks like it must reflect some Direct3D register-count
limit.
It does not. Direct3D 11's constant-buffer element limit is a common shader limit — the same for both stages. Here is what ANGLE actually returns for the fragment stage:
// TODO(geofflang): Remove hard-coded limit once the gl-uniform-arrays test can pass
// ...
case D3D_FEATURE_LEVEL_11_0:
return 1024; // D3D11_REQ_CONSTANT_BUFFER_ELEMENT_COUNT;The real Direct3D constant is commented out on the same line and replaced
with a bare literal, because a WebGL conformance test called gl-uniform-arrays
was too slow to finish with large uniform arrays. That TODO is still there.
The decisive evidence that this is an ANGLE artifact rather than a hardware or API one is in the OpenGL backend, which carries the same workaround under the same comment — but applies it to both stages:
// TODO(geofflang): The gl-uniform-arrays WebGL conformance test struggles to complete on time
// if the max uniform vectors is too large. Artificially limit the maximum until the test is
// updated.
caps->maxVertexUniformVectors = std::min(1024, caps->maxVertexUniformVectors);
// ...
caps->maxFragmentUniformVectors = std::min(1024, caps->maxFragmentUniformVectors);Metal and Vulkan apply it to neither, and assign both stages from a single variable, so they are symmetric by construction — this machine reads 1024/1024, from a 16 KB software uniform budget that has nothing to do with the M2's actual capabilities.
The celebrated 4:1 ratio is therefore not a Direct3D limit, not a GPU characteristic, and not a deliberate design. It is one workaround that got applied to one stage on one backend and both stages on another.
Two caveats before you go checking:
- On NVIDIA the vertex figure is 4095, not 4096. ANGLE subtracts one constant register under a feature gated purely on the PCI vendor ID (
ANGLE_FEATURE_CONDITION(features, skipVSConstantRegisterZero, isNvidia)). The single bit of hardware information in that number is which company made your card, not anything it can do. - Windows is not uniformly Direct3D. A blocklisted GPU or a VM falls back to SwiftShader, which runs ANGLE's Vulkan backend and reports the two stages symmetrically.
Measurement status
| Claim | How established |
|---|---|
Metal reports 1024/1024, SUBPIXEL_BITS 4 |
Measured — Chrome 148, macOS, Apple M2 Max |
| GLSL translated to MSL on macOS | Measured — same machine |
D3D11 fragment limit is a literal 1024 under the geofflang TODO |
Read from ANGLE source; the numeral is verbatim |
Vulkan is the only backend writing subPixelBits |
Verified by whole-tree search of ANGLE |
| D3D11 vertex limit is 4096 | Not verified from source here. D3D11_REQ_CONSTANT_BUFFER_ELEMENT_COUNT lives in the Windows SDK, which is not present on a macOS checkout. It is the well-known SDK value and is corroborated indirectly, but I have not read it in a file |
If you are on Windows and your numbers disagree with any of this, that is a finding — please tell me.
What this means for fingerprinting
WebGL is genuinely one of the higher-entropy surfaces on the web, but it is worth being precise about where the entropy lives:
- The renderer string carries most of it.
ANGLE (Apple, ANGLE Metal Renderer: Apple M2 Max, Unspecified Version)names a vendor, an API, and a specific chip. - The limits carry much less, and much of what they carry is about ANGLE rather than about you. They cluster hard by backend. Their most useful property is not identification but consistency — see below.
- The rendered output — drawing something and hashing the pixels — is a separate signal that behaves much more like canvas.
How much any of this narrows you down depends on the population you are being compared against, which this page has no way to know — so it does not guess.
Spoofing
The renderer string is the most-spoofed value in this dictionary. Privacy
browsers rewrite it, extensions rewrite it, and anti-detect browsers ship
libraries of plausible GPU strings. Tor Browser and Firefox's
resistFingerprinting instead disable or neuter WebGL, which is a different
trade: no signal rather than a false one.
What makes a rewritten string weak is everything above. The string is one value; the limits are dozens, and they are not independent of it. Claim a Direct3D renderer while reporting symmetric uniform vectors and you have contradicted yourself, because the asymmetry is structural on that backend and impossible on Metal and Vulkan. Claim an NVIDIA card on Windows while reporting 4096 rather than 4095 and you have missed a vendor-gated workaround. Then there is the shading language the debug extension hands back, which has to agree too.
This is the same lesson as the gzip OS byte: a value with little entropy on its own becomes strong evidence when read beside a value that is supposed to agree with it.
References
- ANGLE —
src/libANGLE/Caps.hfor the shared defaults,src/libANGLE/Context.cppfor theANGLE_LIMIT_CAPclamp pass, andsrc/libANGLE/renderer/*/for each backend's capability initialization. All quoted code above is from this public tree. - MDN:
getParameter(),WEBGL_debug_shaders,WEBGL_debug_renderer_info. - The Khronos WebGL and OpenGL ES specifications, for the required minimum each limit must meet.
How the probe works
This is the exact source that ran in the panel above — no summary, no drift.
// Reads the WebGL limit set and shows where it actually comes from. Most of
// these numbers are not measurements of your GPU — they are constants from the
// graphics API ANGLE translated your WebGL calls into. The probe also asks for
// your own shader back after translation, which names that API outright.
// Which graphics API is underneath, inferred from the renderer string ANGLE
// composes. This is a label, not a measurement — see the entry text.
function backendFrom(renderer) {
if (!renderer) return "unknown";
if (/SwiftShader/i.test(renderer)) return "SwiftShader (CPU)";
if (/ANGLE Metal Renderer/i.test(renderer)) return "Metal";
if (/Direct3D11|D3D11/i.test(renderer)) return "Direct3D 11";
if (/Direct3D9/i.test(renderer)) return "Direct3D 9";
if (/Vulkan/i.test(renderer)) return "Vulkan";
if (/OpenGL|OpenGL ES/i.test(renderer)) return "OpenGL";
if (/ANGLE/i.test(renderer)) return "ANGLE, backend not named";
return "no ANGLE — native driver";
}
// Name the shading language ANGLE emitted, from unmistakable markers in the
// translated source rather than from the renderer string.
function shadingLanguage(src) {
if (!src) return null;
if (/#include\s*<metal_stdlib>/.test(src)) return "Metal Shading Language (MSL)";
if (/\bcbuffer\b|SV_Position|SV_Target|\bstatic float4\b/.test(src)) return "HLSL (Direct3D)";
if (/#version\s+\d+/.test(src)) return "GLSL (passed through to the host driver)";
return "unrecognised";
}
export async function probe() {
const canvas = document.createElement("canvas");
const gl = canvas.getContext("webgl2") || canvas.getContext("webgl");
if (!gl) throw new Error("no WebGL context — WebGL is disabled or unavailable here");
const dbg = gl.getExtension("WEBGL_debug_renderer_info");
const renderer = dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : null;
const P = (name) => {
try {
const v = gl.getParameter(gl[name]);
return v === undefined || v === null ? null : v;
} catch {
return null;
}
};
const vertexVec = P("MAX_VERTEX_UNIFORM_VECTORS");
const fragVec = P("MAX_FRAGMENT_UNIFORM_VECTORS");
// Ask for our own shader back after ANGLE has translated it. The extension
// is a standard debug extension; it may be absent, which is not an error.
let translated = null;
const shaderExt = gl.getExtension("WEBGL_debug_shaders");
if (shaderExt) {
const isGL2 = typeof WebGL2RenderingContext !== "undefined" && gl instanceof WebGL2RenderingContext;
const source = isGL2
? "#version 300 es\nprecision mediump float;\nuniform float uT;\nout vec4 c;\nvoid main(){ c = vec4(sin(uT), 0.5, 1.0, 1.0); }"
: "precision mediump float;\nuniform float uT;\nvoid main(){ gl_FragColor = vec4(sin(uT), 0.5, 1.0, 1.0); }";
const sh = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(sh, source);
gl.compileShader(sh);
if (gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
translated = shaderExt.getTranslatedShaderSource(sh);
}
gl.deleteShader(sh);
}
const value = {
"Renderer string": renderer ?? "hidden (WEBGL_debug_renderer_info unavailable)",
"Graphics API underneath": backendFrom(renderer),
"Your GLSL was translated to": shadingLanguage(translated) ?? "unavailable (WEBGL_debug_shaders absent)",
"MAX_VERTEX_UNIFORM_VECTORS": vertexVec,
"MAX_FRAGMENT_UNIFORM_VECTORS": fragVec,
"Vertex vs fragment": vertexVec === fragVec ? "symmetric" : `asymmetric (${vertexVec} vs ${fragVec})`,
"SUBPIXEL_BITS": P("SUBPIXEL_BITS"),
"MAX_TEXTURE_SIZE": P("MAX_TEXTURE_SIZE"),
"MAX_TEXTURE_IMAGE_UNITS": P("MAX_TEXTURE_IMAGE_UNITS"),
"MAX_VERTEX_ATTRIBS": P("MAX_VERTEX_ATTRIBS"),
"MAX_VARYING_VECTORS": P("MAX_VARYING_VECTORS"),
};
// WebGL2-only parameters; omit rather than show nulls on a WebGL1 context.
const lodBias = P("MAX_TEXTURE_LOD_BIAS");
if (lodBias !== null) value["MAX_TEXTURE_LOD_BIAS"] = lodBias;
return {
facts: {
"Graphics": renderer
? `${backendFrom(renderer)}${/SwiftShader/i.test(renderer) ? "" : " (hardware)"}`
: backendFrom(renderer),
},
label: `${backendFrom(renderer)} · ${vertexVec}/${fragVec} uniform vectors`,
value,
};
}