UNMASKED_RENDERER_WEBGL returns null until the page has called getExtension('WEBGL_debug_renderer_info') on that context. On a fresh context, Chrome answers getParameter(0x9246) with null and leaves gl.getError() at 1280, which is INVALID_ENUM. If your code gets null, call getExtension first. If you are building a fingerprint spoof, the same fact is a trap: answering that constant early is a tell that no renderer string can fix.
Why it returns null
WEBGL_debug_renderer_info is an extension, and WebGL treats an extension's enums as unknown until the page enables it. Enabling means calling getExtension with its name and getting an object back. The extension defines two constants, UNMASKED_VENDOR_WEBGL (0x9245) and UNMASKED_RENDERER_WEBGL (0x9246), and only after that call does getParameter accept them.
So the working code reads them from the extension object:
const gl = document.createElement('canvas').getContext('webgl')
const ext = gl.getExtension('WEBGL_debug_renderer_info')
const renderer = ext && gl.getParameter(ext.UNMASKED_RENDERER_WEBGL)
Skip the second line and pass the raw number, and Chrome returns null and records the error. The plain constants are a different thing: gl.getParameter(gl.RENDERER) needs no extension and returns WebKit WebGL in Chrome, and gl.VENDOR returns WebKit, the same on every machine.
If getExtension itself returns null, the browser is not offering the extension on that context, and there is no unmasked string to read.
Every profile is drawn from one real computer, not from separate listsIn Mosaic: Machines that exist. GPU, screen, CPU and fonts from one real device. Free holds 3 profiles. Pro is $49 a month for any number.
Mosaic is a desktop app
Email yourself the link and open it on your computer.
How it works: Machines that exist
The two-line tell
Now look at it from the side of a detector. It can make a fresh context, skip getExtension, and ask for 0x9246 directly. A real Chrome answers null, and getError() says 1280. That costs two lines and asks for no permission.
A spoof written the obvious way overrides getParameter, checks whether the argument is 0x9245 or 0x9246, and returns the claimed string. It answers every time, including on a context that never asked. That browser now does something no real Chrome does, and the value of the string does not matter. It could be the machine's own GPU, word for word, and the probe would still separate it from every real browser, because the tell is the moment of the answer, not its content.
We had exactly this. Before our own engine, Mosaic changed values with a script injected into stock Chrome, and its WebGL override answered both constants unconditionally. Pinning the string to the host's own chip could not have helped, for the reason above. The fix in that script was to remember which contexts had asked: getExtension adds the context to a WeakSet when the name is WEBGL_debug_renderer_info and an object comes back, and getParameter only answers for contexts in the set. Everything else falls through to the real implementation, which produces the null and the error as it should.
On our engine, the check is Chromium's own
Tesseract Chrome, our Chromium, changes fingerprint values in C++ inside the browser, not in page JavaScript. Patch 0120 answers the two unmasked constants from the profile, and where it puts the answer is the whole point. In webgl_rendering_context_base.cc the stock code already reads:
case WebGLDebugRendererInfo::kUnmaskedRendererWebgl:
if (ExtensionEnabled(kWebGLDebugRendererInfoName)) {
return WebGLAny(script_state, String(ContextGL()->GetString(GL_RENDERER)));
}
The patch puts the profile's renderer inside that if, above the stock return, and leaves the ExtensionEnabled check untouched. A context that never asked falls out of the if into Chromium's own null-and-error path, which is the same code a stock Chrome runs. No list of contexts to keep, because Chromium already keeps it.
That hunk has a history worth knowing if you build on other people's patches. The upstream version anchored it on a feature flag that only one Chromium fork has, so on stock source the hunk could not apply. It failed in silence: a .rej file beside a tree that still compiled, and the rest of the GPU patch shipped with its helpers built and nothing calling them. The renderer claim was simply never answered, and nothing in the build said so. We re-wrote the hunk against stock context, and the first draft of that landed in the middle of a two-line statement and cost a compiler error about a function-style cast, which had nothing to do with the real mistake.
The string is not the whole claim
Answering at the right moment fixes the two-line tell. It does not make any renderer string believable on its own. A detector that reads the unmasked renderer usually reads the rest of the context too: every MAX_* limit, the shader precision formats, the extension list. Pixelscan's collector has a field for that record next to the two strings. A renderer has to agree with all of it, which is why the engine moves the capability record together with the string.
The other WebGL surface a page can cross-check, the image, has the same lesson in a different shape: two ways to read one framebuffer that have to agree, covered in readPixels and toDataURL. How CreepJS flags a function that answers when it should not is in what a CreepJS lie is, and the values Tesseract Chrome changes are listed on real fingerprints.



