WebGL shader precision as a fingerprint surface

WebGL shader precision is a third browser-fingerprinting surface, read with getShaderPrecisionFormat and hashed separately from the numeric getParameter limits and the getSupportedExtensions list. It is the component most likely to reveal a browser running on one operating system while claiming another, because it is produced deep in the graphics stack where a spoofed user agent string cannot reach it.

Most WebGL fingerprinting write-ups stop at two things: the numeric parameters you read with getParameter, and the extension list you read with getSupportedExtensions. There is a third surface that gets hashed separately from both, and it is the one that most often gives away a browser claiming an operating system it is not running on.

That surface is getShaderPrecisionFormat. This page explains what it returns, why a detector treats it as its own component, how it diverges when the host OS and the claimed OS disagree, and how to read all three WebGL components yourself so you can see whether they tell one story or three.

What getShaderPrecisionFormat returns

getShaderPrecisionFormat returns an object with three integer fields: rangeMin, rangeMax and precision. These describe the numeric range and the number of bits the GPU pipeline guarantees for a given precision qualifier in a given shader stage. Every WebGL context exposes the method, and while most sites never call it by hand, every serious fingerprinting library does:

gl.getShaderPrecisionFormat(shaderType, precisionType)

There are two shader stages (VERTEX_SHADER and FRAGMENT_SHADER) and six precision types (LOW_FLOAT, MEDIUM_FLOAT, HIGH_FLOAT, LOW_INT, MEDIUM_INT, HIGH_INT), so a detector that wants full coverage collects twelve triples in total.

Those twelve triples are not free-form. On a real Windows machine, WebGL runs through ANGLE, which translates it to Direct3D, and ANGLE reports a fixed, feature-level-derived block that is the same across essentially every GPU sold in the last decade. That is the same reason the numeric WebGL parameters are identical on every GPU: the backend clamps to a common ceiling, so the “high end card” you might be tempted to claim reports the exact same limits as an old integrated chip.

Why it is a third, separate hash

A fingerprinting library does not fold shader precision into the numeric parameter hash. It hashes it as its own component, alongside two others:

WebGL component Read with What it captures
Numeric parameters getParameter max texture size, viewport dims, varying vectors, and other numeric limits
Extension list getSupportedExtensions the set of supported WebGL extensions
Shader precision getShaderPrecisionFormat twelve rangeMin / rangeMax / precision triples across two shader stages and six precision types

Three components, three hashes. You can match the first two perfectly and still be caught on the third, because it comes from a different code path in the graphics stack and is generated by a different backend than the one your getParameter numbers imply. This is why isolating it matters: renderer strings can say one thing while the pixels say another, and the shader precision block is a second place where the string you report and the machine you run on can quietly disagree.

Where the mismatch comes from

The interesting failure is not a desktop Windows browser. On a real Windows host the native ANGLE backend already produces the canonical block, so there is nothing to fix. The failure is a browser built and run on Linux that presents itself as Windows, which is the normal shape of a server-side automation deployment.

On Linux, WebGL runs through the Mesa stack rather than ANGLE. Mesa reports its own shader precision ranges, and they are not the ANGLE block. So a browser that has set its user agent, its platform string and its renderer string to Windows, and matched its numeric parameters, still hands back a Linux-shaped shader precision hash. The numeric component says Windows, the extension component says Windows, and the third component says Linux.

We measured exactly this. On a Linux host claiming Windows, the shader precision component hashed to 1aabf55167... (the Mesa block) where a real Windows browser produces f223dfbcd580... (the ANGLE block). That single divergent component was enough to move a commercial fingerprinting service’s tampering model score from about 0.5 to 0.04 once it was brought into line, together with a font fix. A component that most guides never mention was carrying half the tampering signal.

The fix is to make the twelve triples the fixed canonical Windows ANGLE block regardless of host OS, at the engine level, so the third hash agrees with the first two. On a real Windows host that override is unnecessary and stays inert, because native ANGLE is already the canonical block. This is the same principle behind keeping canvas and WebGL byte-identical across operating systems: the value a page can read is made a property of the claimed identity, not of the box the browser happens to run on.

Measuring it yourself

You do not need a detector to see this. You can read all twelve triples directly and compare them against a stock browser, which is the comparison method that catches what verdicts miss. Here is a runnable probe using the real API, which returns a standard Playwright Browser:

from invisible_playwright import InvisiblePlaywright

PROBE = r"""
() => {
  const canvas = document.createElement('canvas');
  const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
  if (!gl) return { error: 'no webgl' };

  const stages = { VERTEX_SHADER: gl.VERTEX_SHADER, FRAGMENT_SHADER: gl.FRAGMENT_SHADER };
  const precs = ['LOW_FLOAT', 'MEDIUM_FLOAT', 'HIGH_FLOAT',
                 'LOW_INT', 'MEDIUM_INT', 'HIGH_INT'];

  const out = {};
  for (const [stageName, stage] of Object.entries(stages)) {
    for (const p of precs) {
      const f = gl.getShaderPrecisionFormat(stage, gl[p]);
      out[stageName + '.' + p] = [f.rangeMin, f.rangeMax, f.precision];
    }
  }
  return out;
};
"""

with InvisiblePlaywright(seed=42) as browser:
    page = browser.new_page()
    page.goto("https://example.com")
    triples = page.evaluate(PROBE)
    for key, value in triples.items():
        print(f"{key:24} {value}")

Run the same probe in a stock Firefox on a real Windows machine and diff the output field by field. Every one of the twelve triples should match. A common real Windows result for the two float-heavy rows looks like this:

FRAGMENT_SHADER.HIGH_FLOAT   [127, 127, 23]
VERTEX_SHADER.HIGH_FLOAT     [127, 127, 23]

If your automated browser reports different numbers there while claiming Windows, that is the mismatch this page is about. To confirm the three WebGL components independently, extend the probe to also print getParameter limits and getSupportedExtensions, and check that all three agree on the same operating system rather than only the two everyone already talks about.

Because the identity is seed-derived, the same seed gives the same twelve triples on every run and on every host, which is what makes a diff meaningful: a difference is the site or the code changing, never the browser drifting under you.

with InvisiblePlaywright(seed=42) as browser:
    page = browser.new_page()
    page.goto("https://example.com")
    first = page.evaluate(PROBE)

with InvisiblePlaywright(seed=42) as browser:
    page = browser.new_page()
    page.goto("https://example.com")
    second = page.evaluate(PROBE)

assert first == second, "shader precision must be stable for a fixed seed"
print("shader precision block is reproducible")

How the three components are kept in agreement

The design goal is simple to state and easy to get wrong: the numeric parameters, the extension list and the shader precision triples must all describe the same machine. Matching two of three is worse than matching none, because two matched components and one divergent one is precisely the internal contradiction a tampering model is built to score.

invisible_playwright handles all three at the engine level, in the same C++ layer that serves the renderer string and the canvas readback, so a page cannot find a seam between them from JavaScript. On a real Windows host the native backend already produces the canonical values and nothing is overridden. On a Linux host presenting as Windows, the numeric block, the extension list and the twelve shader precision triples are all pinned to the same canonical Windows ANGLE values, so the third hash lands on the Windows block rather than the Mesa one. The renderer string names a real Windows GPU that sits consistently on top of that block.

None of this is something you configure. It is the default behaviour of the seed-derived identity, and the probe above is how you verify it rather than trust it.

Conclusion

Shader precision is the WebGL fingerprint component that hides in plain sight. It is hashed separately from the numeric parameters and the extension list, it comes from a different part of the graphics stack, and it is the one most likely to still say Linux after everything visible has been set to Windows. Read all three components, diff them against a stock browser, and treat any single divergent one as a failure even when the other two are perfect. That is the whole discipline: a fingerprint that is internally consistent across every component beats one that scores well on the two components a guide happened to mention.

Short answers to the questions that lead here

What is getShaderPrecisionFormat used for in fingerprinting? It returns rangeMin, rangeMax and precision for each shader stage and precision type. A detector collects the twelve triples and hashes them as a WebGL component separate from the numeric parameters and the extension list.

Is shader precision the same as the other WebGL parameters? No. It is a distinct hash from a distinct code path. You can match the numeric limits and the extension list exactly and still be caught on shader precision if it reports a different backend.

Why does my browser report different shader precision than a real Windows machine? Because it is almost certainly running on Linux through Mesa while claiming Windows, and Mesa’s ranges are not the ANGLE block that a real Windows browser reports.

Can I fix shader precision from JavaScript? Not credibly. Overriding the method in the page is itself detectable, and it only patches the one call while leaving the rest of the graphics stack inconsistent. It has to be correct at the engine level.

How do I test my shader precision values? Read all twelve triples with the probe on this page, run the same probe in a stock browser on a real machine of the OS you claim, and diff them field by field.

Do I need to configure this in invisible_playwright? No. The three WebGL components are kept consistent by default from one seed. The code here is for verifying that, not for enabling it.

Sources

  • The WebGL specification for getShaderPrecisionFormat, getParameter and getSupportedExtensions, read from the spec rather than from a summary of it, and the MDN reference for getShaderPrecisionFormat.
  • This project’s own cross-OS measurements: the shader precision component moving from the Mesa hash to the Windows-equivalent hash on a Linux host claiming Windows, and the matching drop in a commercial fingerprinting service’s tampering model score.
  • The WebGL parameters and renderer string notes in this set, which cover the other two components of the same fingerprint.

See also: why the WebGL numbers are identical on every GPU, what a renderer string can and cannot hide, and canvas and WebGL fingerprints that stay identical across operating systems.


Written while maintaining invisible_playwright, a Firefox patched at the C++ level driven by stock Playwright. The shader precision component was carrying half a tampering signal that no numeric-parameter check would ever have found.


Back to top

MIT licensed. Every page here is written against the current source of the thing it describes, and several record something we got wrong first.

This site uses Just the Docs, a documentation theme for Jekyll.