Skip to content

WebGL fingerprinting: the renderer string, precision, and shader quirks

· 24 min read
Copyright: MIT
The word WEBGL in JetBrains Mono with an orange underline and the caption 'ANGLE (NVIDIA, ...)'

Ask a browser to draw a triangle and read the pixels back, and you have not just drawn a triangle. You have taken a measurement of the machine. The exact bytes that come out of a WebGL render depend on the GPU model, the driver version, the OS graphics stack, the compositor, and the floating-point behaviour of the shader compiler. None of that was meant to be an identity. All of it is.

WebGL hands the open web a thin layer over the system’s graphics driver, and a thin layer is a thin disguise. A script can read the GPU’s vendor and renderer strings in plain text, enumerate every extension the driver advertises, query the numeric range and precision of the shader number formats, and then render a scene and hash the result. Each of those is a few lines of JavaScript. Together they form one of the highest-entropy signals a website can collect without permission, which is why every serious anti-bot vendor reads them and why three of the four major browsers now lie about at least one of them.

This post is a single-vector deep dive into that surface. It starts with the renderer and vendor strings, the most direct leak and the one ANGLE made worse. Then the supported-extensions list and the numeric parameters, which describe the driver’s shape even when the name is hidden. Then shader precision, where IEEE rounding turns into a fingerprint. Then rendered-image hashing, the part that survives a spoofed name because it measures behaviour, not labels. Then the consistency checks that anti-bot vendors layer on top, where the WebGL block is cross-examined against the user-agent and the other signals to catch a lie. The post closes on mitigations: Firefox’s bucketing, Safari and Tor’s constant values, what privacy.resistFingerprinting actually disables, the WebGPU surface that inherits the same problem, and why none of it fully closes the hole.

A note on scope before we start. This is a defensive reference. It explains how the measurement works and what each value means, so you can reason about what a browser leaks and why a mitigation does or does not help. It does not hand you a working spoof. The interesting engineering is in understanding which signals are correlated, because that correlation is what defeats the naive spoof and what a careful defender relies on.

The renderer and vendor strings

The most direct WebGL leak has its own extension. WEBGL_debug_renderer_info exposes two constants, UNMASKED_VENDOR_WEBGL and UNMASKED_RENDERER_WEBGL, which you pass to getParameter on a live context to get back the GPU’s vendor and renderer as human-readable strings. The extension was added so developers could log which GPU a bug report came from. It does exactly that, for trackers too.

Without the extension, getParameter(gl.VENDOR) and getParameter(gl.RENDERER) return deliberately generic values. WebGL implementations historically reported something like WebKit and WebKit WebGL regardless of the hardware underneath, which is useless for identification and was the point. The base WebGL spec masked the real names on purpose. The debug extension was the escape hatch that put them back on the table, and once it shipped widely, fingerprinting scripts simply requested it. The mechanics are three lines: get a context, call getExtension('WEBGL_debug_renderer_info'), then pass the extension’s UNMASKED_RENDERER_WEBGL constant to getParameter. There is no permission prompt and no user-visible sign that it happened.

The fact that the extension has to be explicitly requested is itself a signal. A page that calls getExtension('WEBGL_debug_renderer_info') is announcing interest in the hardware identity, and a browser that wants to resist fingerprinting can watch for exactly that call. More importantly, whether the extension is even available varies by browser and configuration, so its presence or absence is a low-bit fingerprint before any string is read. This is why the strongest mitigations remove the extension wholesale rather than blanking the value it returns.

The renderer string is unusually rich. On Windows, Chromium does not talk to the GPU driver directly. It routes WebGL through ANGLE, which translates OpenGL ES calls into Direct3D, and ANGLE writes its own translation details into the renderer string. The result is a structured blob that names the vendor, the exact GPU model, the Direct3D feature level, and the shader model versions. A representative value looks like this:

ANGLE (NVIDIA, NVIDIA GeForce RTX 2060 SUPER Direct3D11 vs_5_0 ps_5_0, D3D11) vendor GPU model (full SKU, including "SUPER") backend + shader model (vs_5_0 / ps_5_0) *The ANGLE renderer string packs vendor, exact GPU SKU, graphics backend, and shader-model versions into one field. Each component adds entropy.*

Compare what the same field looks like across platforms. On Apple Silicon the Metal backend writes ANGLE (Apple, ANGLE Metal Renderer: Apple M1 Pro, Unspecified Version). On Android the string is the raw driver name, like Adreno (TM) 650 or Mali-G57 MC2. And when no GPU is available at all, Chromium falls back to its software rasterizer and the string becomes ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver). That last one is the tell. A SwiftShader or llvmpipe renderer string almost never appears on a real consumer device with a working display. It shows up in headless containers, virtual machines without GPU passthrough, and CI runners. Anti-bot systems treat it as a strong negative signal, which is the same logic covered in how anti-bot systems fingerprint the JavaScript runtime: a value that is technically valid but statistically improbable for a human’s hardware.

Worth pausing on why the Windows string is the richest of the lot. ANGLE exists because Windows did not historically ship a reliable OpenGL driver, so Chromium translates WebGL’s OpenGL ES calls into Direct3D and runs them through whatever D3D driver is installed. That translation layer is honest about itself: it writes the backend it chose (D3D11 versus the older D3D9), the Direct3D feature level, and the vertex- and pixel-shader model versions (vs_5_0, ps_5_0) straight into the renderer string. So a Windows machine leaks not just the GPU but the graphics-API path the browser took to reach it, and that path shifts with Windows version, driver release, and ANGLE’s own heuristics. On macOS and Linux, where Chromium can talk to a Metal, Vulkan, or OpenGL driver more directly, the string carries fewer ANGLE artifacts and is correspondingly lower-entropy, though still more than enough to name the GPU family.

The mobile case is its own thing. Android renderer strings like Adreno (TM) 650 or Mali-G57 MC2 name the GPU IP block, not a retail SKU, so they bucket more coarsely than desktop strings: many phone models ship the same Adreno or Mali block. But mobile makes up the difference elsewhere. The shader-precision profile, covered below, splits mobile GPUs sharply because their drivers genuinely use lower-precision number formats, and the combination of a coarse renderer name with a distinctive precision profile is still a tight identifier.

The entropy here is real. The renderer string alone can split the population into thousands of buckets, because it carries the exact GPU SKU, the driver’s chosen backend, and, on Windows, ANGLE’s translation metadata layered on top. Before any mitigation, one measurement of WebGL renderer strings in the wild found tens of thousands of distinct values. That is the number the browsers set out to crush.

Supported extensions and numeric parameters

Hide the name and the driver still describes itself. getSupportedExtensions() returns the list of WebGL extensions the implementation advertises, and that list is a function of the GPU, the driver version, and the browser build. Two machines running the same Chrome version but different driver releases can expose different extension sets. The order is generally stable for a given build, so a script can concatenate the array, hash it, and use the digest as a compact descriptor of the driver’s capabilities.

Alongside the extensions sit the numeric limits. A WebGL context answers getParameter for dozens of capability constants, and each one is a hardware or driver ceiling. MAX_TEXTURE_SIZE and MAX_CUBE_MAP_TEXTURE_SIZE describe texture memory limits. MAX_VIEWPORT_DIMS returns the largest renderable surface. MAX_VERTEX_ATTRIBS, MAX_VERTEX_UNIFORM_VECTORS, MAX_FRAGMENT_UNIFORM_VECTORS, and MAX_VARYING_VECTORS describe the shader resource budget. ALIASED_LINE_WIDTH_RANGE and ALIASED_POINT_SIZE_RANGE return two-element arrays that vary by rasterizer. The framebuffer’s RED_BITS, GREEN_BITS, BLUE_BITS, DEPTH_BITS, and STENCIL_BITS describe its pixel layout.

Any single one of these is low-entropy. A few hundred GPUs share the same MAX_TEXTURE_SIZE. But the joint distribution of thirty of them is sharp, and that is how fingerprinting works generally: each attribute is weak, the combination is strong. This is the same argument that the Beauty and the Beast study made for the whole browser surface back in 2016, when it collected 118,934 fingerprints and found 89.4% of them unique, with the canvas and WebGL attributes among the higher-entropy contributors. The WebGL parameter block is a microcosm of that finding inside one API.

The reason the combination is strong even though each value is weak is that the values are not independent, but they are also not perfectly correlated. MAX_TEXTURE_SIZE is 16384 on a huge share of modern desktop GPUs, so on its own it eliminates almost nobody. But pair it with a specific MAX_VERTEX_UNIFORM_VECTORS, a specific ALIASED_POINT_SIZE_RANGE upper bound, and a particular framebuffer bit-depth layout, and you are now inside a much smaller cell of the population. Each constraint is a coordinate. A point in a 30-dimensional space is easy to isolate even when every individual axis is coarse. This is also why blanking one or two parameters does little: the entropy lives in the joint distribution, so a defender has to flatten many values at once to move the needle, which is exactly the all-or-nothing problem the browsers ran into.

A subtle property of the extensions list specifically: it tracks the driver more tightly than the GPU. The same physical card under two driver releases can advertise different extension sets, because a driver update is where new WebGL extensions become available. So the extensions hash is partly a driver-version fingerprint, and because driver versions update on their own schedule, it drifts over time in a way the GPU model does not. A detection system that stores the extensions hash alongside the renderer string can sometimes watch the extensions change while the renderer stays fixed, which is the signature of a driver update on the same machine, and that is itself a linkage signal across sessions.

four signal families, one descriptor renderer / vendor strings supported extensions numeric limits MAX_*, *_BITS shader precision precision/rangeMin/Max report hash sha256( signals ) *The static side of WebGL fingerprinting: strings, extensions, limits, and precision concatenated into one report hash. The rendered-image hash, covered later, is computed separately.*

Shader precision: when IEEE rounding becomes identity

getShaderPrecisionFormat is the sharpest of the parameter probes because it reaches into the numeric behaviour of the shader compiler. You call it with a shader type (vertex or fragment) and a precision qualifier (HIGH_FLOAT, MEDIUM_FLOAT, LOW_FLOAT, and the integer variants), and it returns a WebGLShaderPrecisionFormat object with three integer fields: rangeMin, rangeMax, and precision.

The definitions are precise. rangeMin and rangeMax are the base-2 logarithms of the smallest and largest representable magnitudes; precision is the number of bits of mantissa. For a true IEEE 32-bit float in a high-precision fragment slot, the answer is rangeMin: 127, rangeMax: 127, precision: 23. That triple is the signature of a GPU that does highp float work in full 32-bit precision. Now ask the same question for MEDIUM_FLOAT or LOW_FLOAT, and on mobile GPUs the answer changes, because many mobile drivers implement mediump as a 16-bit half-float and lowp as something narrower still. Desktop drivers usually promote everything to 32-bit. So the same six queries (two shader types times three float qualifiers) return a pattern that separates desktop from mobile, and separates GPU families within mobile.

This matters more than it first looks. The precision format does not just describe what the driver advertises. It predicts how shader arithmetic will round, which feeds directly into the rendered-image hash discussed next. A GPU that computes a transcendental in mediump produces visibly different pixels from one that computes it in highp. The precision query is the static, cheap preview of a difference the render makes concrete.

It is also one of the hardest values to spoof convincingly, for a reason that becomes the whole story later. The precision triple is not a free parameter. It is bound to the actual numeric behaviour of the shader compiler. A spoof that returns precision: 23 while the underlying driver renders at 16-bit precision has created a contradiction that any render will expose. The advertised precision says highp; the pixels say mediump. A detection script that both queries the precision format and hashes a precision-sensitive render can compare the two and notice they disagree. So the precision query is not just another entropy source. It is a tripwire that links the static metadata to the dynamic render, and the link is the part that is hard to fake.

Rendered-image hashing

The string and parameter probes can all be spoofed by a determined client, because they are just values returned from function calls. A patched browser can hand back any renderer string it likes. The render itself is harder to fake, because it is not a value the JavaScript engine controls. It is the output of the GPU and its driver doing actual math.

The technique is straightforward to describe. The script creates an offscreen WebGL context, compiles a vertex and fragment shader, draws a scene, and reads the framebuffer back with readPixels (or reads the canvas with toDataURL). It then hashes the pixel buffer. The same shader, fed the same inputs, produces subtly different pixels on different GPUs, and those differences are stable for a given hardware-and-driver combination. The hash is the fingerprint.

Where do the differences come from? Floating-point execution is not bit-identical across GPUs. The graphics-research community studied this directly. The 2019 USENIX Security paper Rendered Private examined why GLSL programs produce divergent output across devices and located the cause in non-deterministic floating-point execution: differences in how drivers round intermediate results, and in how they implement transcendental functions like sin, cos, pow, and exp, which the standard does not pin to a single bit-exact algorithm. Two drivers can both be conformant and still disagree on the last bits of a sine. Multiply that disagreement across a million pixels of a shader-heavy scene and the resulting image hashes diverge cleanly. The paper’s response was to compile GLSL to a uniform, deterministic execution that removes the divergence, which by construction would also remove the fingerprint.

The practical fingerprinting scenes lean into exactly the operations that diverge. Anti-aliasing on curved edges, gradients, alpha blending, and transcendental-heavy lighting all amplify the per-driver rounding differences. A flat-shaded square hashes the same everywhere. A rotated, anti-aliased, gradient-filled scene with a few pow calls does not.

same GLSL shader + inputs NVIDIA / D3D11 Apple M-series / Metal Adreno / mobile

a4f1… 9c0e… 71bd…

distinct image hashes from identical source Identical GLSL produces divergent pixels because floating-point rounding and transcendental implementations differ across drivers. The image hash captures that divergence.

One nuance worth holding onto. The image hash is stable for a hardware-and-driver pair, but it is not perfectly stable forever. A driver update can shift the rounding behaviour and move the hash. This is a feature for the user and a headache for the tracker: the renderer string and the image hash are correlated but not redundant, and a careful detection system stores both so it can notice when one changes without the other. That kind of cross-signal consistency check is the same pattern anti-bot vendors apply across the whole stack, from TLS fingerprinting up through the JavaScript runtime.

The readback path deserves a closer look because it is where the technique becomes blockable. There are two ways out of a WebGL context: readPixels, which copies the raw framebuffer into a typed array the script can hash directly, and toDataURL or toBlob on the canvas element, which serialize the composited result. Both expose the rendered bytes. This is why the canvas readback and the WebGL readback are usually discussed together; the WebGL render lands in a canvas, and the canvas is the exfiltration point. The defensive moves all target this boundary. Tor disabled the readPixels path specifically. Firefox under resistFingerprinting interferes with canvas readback so the bytes a script gets back are not the raw render. The render still happened on the real GPU; the script just cannot read the result faithfully. The full story of canvas-side readback is its own topic, covered in canvas fingerprinting; here the relevant point is that WebGL and canvas share the same exit door, and closing it closes both.

A practical detail on how the scene is built. The shaders fingerprinting scripts use are deliberately small and deterministic in their inputs, because the goal is reproducibility: the same script must produce the same hash on the same machine across page loads, or the fingerprint is useless. So the scene is fully specified in the script, with fixed geometry and fixed uniforms, and the only variable is the hardware. That determinism on the input side is what makes the hardware-induced divergence on the output side legible. If the inputs wandered, you could not tell a GPU difference from an input difference.

Consistency: how a spoof gets caught

Every value in the WebGL block can be overridden by a client that controls its own browser build. The renderer string is just a string; hand back any one you like. The extensions list is an array; return whatever set you choose. The precision triple is three integers; lie about them. Anti-detect browsers and stealth tooling do exactly this, swapping a headless container’s real SwiftShader profile for a plausible NVIDIA-on-Windows one. The problem is that the values are not independent, and a defender who knows the joint distribution can check whether a claimed profile is internally coherent.

The simplest check is the renderer-against-render comparison already described. Claim an NVIDIA RTX renderer string, and the image hash had better match what an NVIDIA RTX actually produces for the test scene. If the spoofed machine is really a software rasterizer, the render gives it away no matter what string it advertised. A faithful spoof would have to reproduce the exact floating-point rounding of the claimed GPU, which means either owning that GPU or reimplementing its driver math bit-for-bit, and neither is cheap.

The deeper checks reach outside WebGL entirely. The renderer string names a GPU, which implies an OS and an OS version range, which had better agree with the user-agent and the platform string. An ANGLE Direct3D11 renderer implies Windows; a Metal renderer implies macOS; an Adreno or Mali string implies Android. A client claiming Windows in its user-agent while its renderer string reports a Metal backend has contradicted itself. The same logic extends down the stack to the network layer, where the TLS fingerprint and HTTP/2 behaviour imply an OS that must also agree with the GPU and user-agent. A coherent fake has to be coherent everywhere at once, and that is the cost the defender imposes.

a coherent profile must agree across all four renderer string image hash OS / UA / platform TLS / HTTP2 fingerprint

render must match claimed GPU backend implies an OS stack implies the same OS A spoofed renderer string is cheap; a spoofed renderer string that survives all three consistency checks is expensive. Each arrow is a contradiction a defender can test.

This is the reason a single high-quality signal beats a pile of spoofable ones, and it is why WebGL keeps its value to detection systems even though every individual field is forgeable. The fields constrain each other. An attacker who flips one has to flip all the others in agreement, and the agreement is what is hard. The same principle drives why stealth plugins lose: patching one tell in isolation creates a new inconsistency somewhere else, and the inconsistency is more detectable than the original tell.

What the mitigations actually do

The four major browsers picked four different points on the usability-versus-privacy line, and the differences matter if you are trying to reason about what a given client leaks.

Chrome, and the Chromium-based browsers that inherit its behaviour, give the exact values back. The unmasked renderer string is the real ANGLE blob, the extensions list is the real list, the parameters are the real ceilings, and the image hash is the real render. Chromium ships these surfaces essentially unprotected by default. The reasoning has always been that breaking WebGL breaks too much real content, and that the renderer string is needed for legitimate GPU-blocklist and performance decisions. The cost is that a default Chrome is the most identifiable of the four on this axis.

Firefox took the middle path and bucketed the renderer string. Since Firefox 91, shipped in 2021, the browser groups GPUs into broad families rather than returning the exact SKU, under bug 1715690. The relevant preferences are webgl.override-unmasked-renderer and webgl.override-unmasked-vendor, with webgl.enable-renderer-query controlling whether the sanitized value is exposed at all. The impact is large. One measurement put the distinct-value count before bucketing at 83,705 and after bucketing at 131, a reduction of better than 99%. And the protection is on by default for everyone; it does not require Enhanced Tracking Protection to be enabled. Firefox decided the renderer string is useful enough to keep in coarse form rather than blank it entirely, so a site still learns roughly which GPU family it is talking to without learning the exact card.

Safari and Tor Browser return constant values, or nothing. WebKit has masked the vendor and renderer for years, handing back a fixed string rather than the hardware’s name, so every Safari on a given platform looks the same on that field. Tor Browser goes further and treats WebGL as hostile by default. Its design sets webgl.min_capability_mode and webgl.disable-extensions to shrink the capability surface to a common minimum, and historically it gated WebGL behind a click-to-play prompt so the API does not run silently. Tor Browser 8.5.1 shipped a specific fix that disabled the readPixels readback path used to fingerprint, because that is the call the image-hash technique depends on. Tor’s whole philosophy is to reduce the number of distinguishable buckets to as close to one as the web will tolerate, the same logic it applies to user-agent and screen-size normalization.

renderer-string disclosure, most to least

Chrome / Chromium exact ANGLE string, full SKU

Firefox (FF91+) bucketed: GPU family, not model (83,705 to 131 values)

Safari / Tor constant value, or extension absent entirely The same field, four policies. Bar length is illustrative of how much hardware detail each browser discloses by default.

privacy.resistFingerprinting is the lever that changes Firefox’s behaviour the most, and it is worth being exact about what it does. When RFP is enabled, the WebGL debug-renderer extension is disabled, so getParameter(UNMASKED_RENDERER_WEBGL) no longer returns the real string. This was tracked in bug 1485249, which made WebGL extensions including the debug-renderer extension unavailable under RFP. The point of disabling the whole extension rather than just blanking the value is that the presence or absence of the extension is itself a signal, so RFP normalizes the API surface, not only the data. RFP also changes the read-back path so the rendered image cannot be trivially extracted, which is the same defensive instinct Tor applied to readPixels.

WebGPU, the successor API, inherits the whole problem and adds to it. WebGPU exposes the GPU’s adapter through a requestAdapter call, and the adapter object carries a limits structure with dozens of numeric capability values, the direct analogue of the WebGL parameter block but larger. The browsers learned from the WebGL experience and built mitigation in from the start: Chrome reports tiered limit values rather than the exact hardware ceilings, snapping each limit to one of a small set of buckets so two machines with slightly different real limits report the same tier. That is the Firefox renderer-bucketing idea applied to numeric limits and applied by default. It does not eliminate the entropy, because the tiers still partition the population, and WebGPU also gives a compute path to the same divergent floating-point behaviour that drives WebGL image hashing. The surface moved. The underlying fact, that the GPU is a measurable and stable property of the machine, did not.

There is a catch that runs through all of this. Turning on the strongest protection makes you rarer, not more common. A browser advertising the Firefox-RFP profile, or the Tor minimal-capability profile, is a small enough population that the protection itself becomes a label. The renderer string stops identifying your GPU and starts identifying you as one of the few people hiding their GPU. The defenders know this. It is the central tension of anti-fingerprinting, and it is why the bucketing approach, which keeps a plausible-looking value while collapsing the entropy, has won out over outright blanking in the browser that most people actually use.

Where this leaves the signal

WebGL fingerprinting is durable for a structural reason. The thing it measures, your GPU and its driver, is the same regardless of which browser you open. Chrome, Firefox, and Safari querying the same card get correlated answers, which makes the WebGL surface one of the few cross-browser fingerprints that actually works, where two different browsers on one machine can be linked through the shared hardware underneath. The browsers can lie about the name. They have a harder time hiding the math, because suppressing the rendered-image divergence means either compiling shaders to a uniform execution model, as the Rendered Private work proposed, or refusing to let scripts read pixels back at all, as Tor chose. Both have real costs to the legitimate uses of WebGL, which is why neither has shipped in the default browser of the majority.

So the current state is a stalemate that favours the tracker on Chrome and the user on Tor, with Firefox holding an uneasy middle where the renderer string is coarse but the image hash still leaks. The most reliable single fact to take from all of this: a renderer string reading SwiftShader, llvmpipe, or a constant placeholder is not evidence of a private user so much as evidence of an unusual one, and unusual is the opposite of what hiding is supposed to achieve. The honest GPU and the perfectly hidden GPU both blend in. It is the half-hidden one, the spoofed name with the wrong image hash behind it, that gets caught.


Sources & further reading

Further reading