To detect canvas noise injection, draw the same thing twice on one canvas at two different offsets and compare the two regions byte by byte. A real browser renders identical input to identical pixels, so the difference is 0. Noise keyed on a pixel's position cannot pass, because the two copies sit at different coordinates and get different stamps. When we ran it against our own engine's first canvas noise, the two copies differed by 76 bytes.

The probe

It is short enough to paste into a console:

const c = Object.assign(document.createElement('canvas'), { width: 200, height: 120 }), g = c.getContext('2d')
for (const y of [0, 60]) { g.fillStyle = '#f60'; g.fillRect(10, y + 5, 80, 30); g.fillStyle = '#069'; g.font = '16px sans-serif'; g.fillText('Cwm fjord 1234', 12, y + 28) }
const a = g.getImageData(0, 0, 200, 60).data, b = g.getImageData(0, 60, 200, 60).data
let diff = 0; for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) diff++

The two halves of the canvas received the same instructions, shifted down by 60 whole pixels. On an untouched browser diff is 0. Anything else means something changed the pixels after they were drawn, and changed them differently depending on where they landed.

Why stable noise still fails it

The usual tests for injected canvas noise are known pixels (draw a solid colour, read it back, check nothing moved) and repeat reads (render twice, compare the hashes). Noise that changes on every read fails the second test at once, so better noise is made deterministic, and keying it on the pixel's coordinates is the obvious way to do that.

Our engine's first version did exactly that, and it passed both of the usual tests. Measured on one profile, one canvas, the same text and rectangle drawn at y=0 and y=60:

identical regions differ    76 bytes      (a real browser: 0)
same region read twice      0 bytes
toDataURL stable            true

Every read agreed with every other read. The canvas still contradicted itself, because a real browser draws the same thing the same way wherever it is.

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.

How it works: Machines that exist

What it looked like from outside

Pixelscan's fingerprint card was reading this, and that is why its verdict on our profiles looked random. It only fired when a stamped pixel happened to land inside a region it compared. Four fresh profiles came back consistent, inconsistent, consistent, inconsistent. One profile, bisected field by field, passed twice with canvas noise off and failed six times with it on, while audio, client rects and every other surface made no difference.

Keying the noise on colour

So the noise now keys on what was drawn instead of where. Every fully opaque pixel of a given colour gets the same low bit, chosen once per profile from the profile's seed. The key reads bits 1 to 7 of each channel and the stamp writes only bit 0, so reading a stamped pixel back computes the same key and writes the same value. Identical content is then identical at any offset, through getImageData, toDataURL, a sub-rectangle or a second canvas, and still different from what the bare machine would produce.

The first colour-keyed version touched every opaque pixel, and it failed Pixelscan five times out of five, worse than the noise it replaced. Changing every pixel does not read as two GPUs rounding differently; it reads as a filter over the image. Only about one distinct colour in 32 is touched now. Measured after that change, eight fresh profiles: 8 of 8 passed, with 8 distinct canvas hashes.

Three older rules made it safe to write. We never write to the page's own canvas, only to a copy we encode from. We stamp a bit rather than flip it, because XOR applied twice cancels, and a page that decodes its own data URL onto a second canvas would apply it twice. And we stamp fully opaque pixels only, since a transparent pixel's colour does not survive PNG encoding.

Bromite had the same bug, and we nearly shipped it

Tesseract Chrome, our engine, carries some patches from other Chromium projects. Bromite's canvas function, ShuffleSubchannelColorData, scans for edge pixels and stamps them keyed on where they are. That is the same algorithm the probe catches, and it fails the probe the same way. Carrying it as it was would have put a known Pixelscan failure into a browser built to pass Pixelscan.

Our patch 0125 replaces that function's body, which all six of its call sites go through, with the colour-keyed stamp described above. The lesson we wrote down is about vendored code in general: a patch from another project tells you what that project chose, not what is correct. Read what it does before you carry it.

How to detect canvas noise injection in your own browser

Paste the probe into the console of any profile and read diff. Zero is what a real browser says. A non-zero result with stable repeat reads is position-keyed noise; a result that changes between runs is per-read noise, which repeat reads catch on their own. The hash a site would compute from your canvas is on the canvas fingerprint page, and the fingerprint checker shows the rest of what a page reads.

How engine-level noise differs from the extension kind is covered in what an anti-detect browser is, and our launch post has why Mosaic moves these values in C++ at all.