Canvas fingerprinting: bypass techniques for 2026
Canvas fingerprinting is the oldest browser-side fingerprinting technique that still works. It exploits the fact that drawing the same image on different machines produces subtly different pixel data, because GPU drivers, font rasterization, and antialiasing settings vary across hardware. A site asks the browser to draw a string in a specific font on a hidden canvas, calls toDataURL(), and hashes the result. That hash is then matched against a database of known device fingerprints. Two visits from the same machine produce the same hash. Two visits from your scraper farm produce the same hash if the scrapers are not properly randomized, which is why canvas fingerprinting catches lazy scraper deployments instantly.
This guide covers what modern canvas fingerprinting actually looks at, why simple toDataURL overrides do not work in 2026, and the patterns that do. Code samples target Playwright with Chromium because that is the dominant scraping browser, but the principles apply to any automation stack.
What canvas fingerprinting captures
The standard canvas fingerprinting flow on a target site looks like this:
- Create a hidden
<canvas>element via JavaScript - Draw a fixed string (often
Cwm fjordbank glyphs vext quiz,or a similar pangram with mixed scripts) in a specific font and color - Draw a few geometric primitives (circles, gradients, bezier curves) on top
- Call
canvas.toDataURL()to extract the rendered PNG as a base64 string - Hash the base64 string with SHA-256 or MD5
- Compare the hash against a database
The reason this works as a fingerprint is that the rasterization is deterministic per machine but variable across machines. Subpixel font hinting, GPU-accelerated text rendering, color space conversion, and antialiasing all contribute small differences that get baked into the pixel buffer. Two real users with different graphics cards produce different hashes. A thousand identical Docker containers running headless Chrome produce one hash, repeated.
For a deeper academic background, see Mowery and Shacham’s Pixel Perfect: Fingerprinting Canvas in HTML5. The technique they described in 2012 is essentially what enterprise fingerprinting still does in 2026.
Modern variations beyond toDataURL
Vendors evolved past basic toDataURL because the original was too easy to override. Modern fingerprinting reads pixels through multiple paths to defeat single-method hooks:
canvas.toDataURL("image/png")for the classic PNG hashcanvas.toDataURL("image/jpeg", 0.9)to force JPEG compression which adds different artifactscanvas.toBlob(callback, "image/webp")to use WebP encodingctx.getImageData(0, 0, w, h).datato read raw pixel buffers directlyOffscreenCanvas.transferToImageBitmap()for the offscreen canvas APIWebGL.readPixels()for WebGL canvases (separate but related fingerprinting)ctx.measureText("...").widthfor font metric fingerprinting without rendering
A scraper that overrides toDataURL only is caught by getImageData. A scraper that overrides both is caught by OffscreenCanvas. A complete bypass needs to hook every path that returns pixel data and either return consistent fake data or add controlled noise to the real data.
Why naive overrides fail
The most common bypass attempt is to monkey-patch HTMLCanvasElement.prototype.toDataURL to return a fixed string or a randomized string. Fingerprinters detect this trivially:
// Detection: check if toDataURL is the original
HTMLCanvasElement.prototype.toDataURL.toString().includes('[native code]')
// false if patched, true if native
Or more thoroughly:
// Detection: check if the toDataURL on a fresh canvas
// returns the same thing as the prototype's
const c = document.createElement('canvas');
const ctx = c.getContext('2d');
ctx.fillText('test', 0, 0);
const direct = c.toDataURL();
const fromProto = HTMLCanvasElement.prototype.toDataURL.call(c);
direct === fromProto;
// false if a wrapper changed the output, true if untouched
These are baseline checks in DataDome, PerimeterX, and Akamai’s fingerprinting modules. The fix is to make your override indistinguishable from native, which is harder than it sounds because of Function.prototype.toString integrity checks, frozen prototypes, and trapped property descriptors.
Bypass approach 1: noise injection at the pixel level
The cleanest pattern in 2026 is to add tiny, deterministic noise to actual rendered pixels before they leave the canvas. This produces a fingerprint that is unique per scraping profile (so you can rotate it across instances) but consistent within a single session (so the same fingerprint check on the same page returns the same hash).
// Inject this via Playwright's page.add_init_script before the page loads.
(() => {
const seed = (() => {
// Per-context seed; persists across same-context calls.
if (window.__canvasSeed === undefined) {
window.__canvasSeed = Math.floor(Math.random() * 1e9);
}
return window.__canvasSeed;
})();
const xorshift = (n) => {
n ^= n << 13;
n ^= n >>> 17;
n ^= n << 5;
return n >>> 0;
};
const noiseChannel = (value, key) => {
const noise = (xorshift(key) % 7) - 3;
return Math.max(0, Math.min(255, value + noise));
};
const originalGetImageData = CanvasRenderingContext2D.prototype.getImageData;
CanvasRenderingContext2D.prototype.getImageData = function (sx, sy, sw, sh) {
const data = originalGetImageData.apply(this, arguments);
const pixels = data.data;
let key = seed ^ sx ^ (sy << 8) ^ (sw << 16) ^ (sh << 24);
for (let i = 0; i < pixels.length; i += 4) {
key = xorshift(key + i);
pixels[i] = noiseChannel(pixels[i], key);
pixels[i + 1] = noiseChannel(pixels[i + 1], key + 1);
pixels[i + 2] = noiseChannel(pixels[i + 2], key + 2);
}
return data;
};
const originalToDataURL = HTMLCanvasElement.prototype.toDataURL;
HTMLCanvasElement.prototype.toDataURL = function (...args) {
// Force the canvas to go through getImageData so noise applies.
const ctx = this.getContext('2d');
if (ctx) {
const w = this.width;
const h = this.height;
const noisy = ctx.getImageData(0, 0, w, h);
ctx.putImageData(noisy, 0, 0);
}
return originalToDataURL.apply(this, args);
};
const originalToBlob = HTMLCanvasElement.prototype.toBlob;
HTMLCanvasElement.prototype.toBlob = function (callback, ...args) {
const ctx = this.getContext('2d');
if (ctx) {
const noisy = ctx.getImageData(0, 0, this.width, this.height);
ctx.putImageData(noisy, 0, 0);
}
return originalToBlob.call(this, callback, ...args);
};
})();
The noise is keyed by canvas position and size, so the same canvas in the same session produces the same noisy output. Cross-session, the seed changes, so the fingerprint rotates. The noise magnitude is small (1-3 in each channel) which keeps the visual output indistinguishable from antialiasing artifacts that a real GPU would introduce.
Bypass approach 2: full Function.prototype.toString hook
Vendors detect monkey-patches by checking that function.toString() returns native code. To pass that check, you have to override Function.prototype.toString itself so that your patched functions appear native.
(() => {
const nativeToString = Function.prototype.toString;
const patchedFns = new WeakSet();
Function.prototype.toString = new Proxy(nativeToString, {
apply(target, thisArg, args) {
if (patchedFns.has(thisArg)) {
// Return a synthetic native-looking string
const name = thisArg.name || 'anonymous';
return `function ${name}() { [native code] }`;
}
return Reflect.apply(target, thisArg, args);
},
});
// Mark patched functions
window.__markNative = (fn) => {
patchedFns.add(fn);
return fn;
};
})();
Then in your canvas patch above, wrap the override:
HTMLCanvasElement.prototype.toDataURL = window.__markNative(function (...args) {
// ... noise injection ...
return originalToDataURL.apply(this, args);
});
This is what tools like puppeteer-extra-plugin-stealth do internally. It is fiddly to maintain because every Node and Chrome update can break the integrity checks. For production, prefer a maintained stealth plugin over rolling your own.
Bypass approach 3: Playwright with stealth via patchright or rebrowser
Two production-grade Playwright forks ship in 2026:
- patchright: Python and Node fork of Playwright with built-in stealth patches including canvas, WebGL, audio, and font fingerprinting. Drop-in replacement for
playwright. - rebrowser-playwright: similar concept, includes runtime detection countermeasures and canvas noise injection out of the box.
Using patchright in Python:
from patchright.async_api import async_playwright
async def stealth_fetch(url, proxy):
async with async_playwright() as p:
browser = await p.chromium.launch(
headless=True,
proxy=proxy,
args=[
"--disable-blink-features=AutomationControlled",
"--disable-features=IsolateOrigins",
],
)
ctx = await browser.new_context(
viewport={"width": 1920, "height": 1080},
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
device_scale_factor=1,
color_scheme="light",
)
page = await ctx.new_page()
await page.goto(url, wait_until="networkidle")
html = await page.content()
await browser.close()
return html
Patchright applies canvas noise automatically per context, plus WebGL, audio, and other fingerprint surfaces. For most scraping work in 2026, this is the path of least resistance compared to maintaining your own stealth scripts.
Bypass approach 4: Stagehand with AI-driven actions
Stagehand by Browserbase is an AI-driven scraping framework that runs a real Chrome under the hood with built-in anti-fingerprinting. It costs more per page than Playwright but eliminates the maintenance burden of stealth patches.
import { Stagehand } from "@browserbasehq/stagehand";
const stagehand = new Stagehand({
env: "BROWSERBASE",
apiKey: process.env.BROWSERBASE_API_KEY,
projectId: process.env.BROWSERBASE_PROJECT_ID,
enableCaching: false,
});
await stagehand.init();
await stagehand.page.goto("https://target.example.com/products");
const products = await stagehand.page.extract({
instruction: "Extract all product names and prices on this page",
schema: z.object({
products: z.array(z.object({
name: z.string(),
price: z.number(),
})),
}),
});
await stagehand.close();
Browserbase’s hosted browsers run with anti-fingerprinting baked in. The tradeoff is cost: roughly 5-10x more than running Playwright on your own infrastructure, but zero maintenance.
Verifying your canvas fingerprint
The standard test sites for canvas fingerprinting:
| site | what it shows | format |
|---|---|---|
| browserleaks.com/canvas | hash + visual diff | HTML, easy to read |
| amiunique.org | full fingerprint suite | HTML report |
| fingerprint.com/demo | commercial-grade fingerprint | JSON via API |
| coveryourtracks.eff.org | EFF’s fingerprint test | HTML report |
Run your scraper against browserleaks.com/canvas and compare the hash across multiple runs. With proper noise injection, the hash should change across sessions and stay stable within a session. Without noise, the hash is identical every run from the same Docker image, which is the smoking-gun signature of a scraper farm.
from patchright.async_api import async_playwright
async def check_canvas_fp():
async with async_playwright() as p:
for run in range(5):
browser = await p.chromium.launch(headless=True)
ctx = await browser.new_context()
page = await ctx.new_page()
await page.goto("https://browserleaks.com/canvas")
# Wait for the hash to appear
await page.wait_for_selector("#canvas-fp")
hash_value = await page.text_content("#canvas-fp")
print(f"Run {run + 1}: {hash_value}")
await browser.close()
If all five runs return the same hash, your scraper farm is fingerprintable as one device. If each run returns a different hash, you have proper rotation.
Comparison: bypass approaches
| approach | difficulty | maintenance | cost | success rate (2026) |
|---|---|---|---|---|
| naive toDataURL override | trivial | low | free | very low, detected immediately |
| custom noise injection | medium | medium | free | high if maintained |
| patchright/rebrowser | low | low | free | high, maintained by community |
| puppeteer-extra-stealth | low | medium | free | medium, less maintained in 2026 |
| Stagehand/Browserbase | trivial | none | $$ | very high |
| undetected-chromedriver | low | low | free | high for Selenium users |
For most teams, patchright + a real residential proxy is the right starting point. It is free, drop-in, and handles canvas plus the other major fingerprinting surfaces in one package. Move to Browserbase when you need more reliability or scale than self-hosted infra can provide.
For broader patterns on driving full browsers in scraping, see Stagehand vs Playwright for AI-driven scraping.
What about font fingerprinting
Canvas fingerprinting often pairs with font enumeration. The site renders text in a specific font, then probes which fonts are installed by drawing strings and measuring widths. Headless Chrome on a default Linux container has a different font set than a real Mac or Windows desktop, which itself is a flag.
The fix is to install a representative font set in your Chrome runtime. For Linux containers, install the fonts-noto, fonts-liberation, fonts-dejavu, and fonts-roboto packages, plus a Microsoft fonts package if you can license one. This brings the font set close enough to a Windows or Mac default to pass enumeration checks.
FROM mcr.microsoft.com/playwright/python:v1.45.0-jammy
RUN apt-get update && apt-get install -y \
fonts-noto fonts-noto-cjk fonts-noto-color-emoji \
fonts-liberation fonts-dejavu fonts-roboto \
fonts-freefont-ttf \
&& rm -rf /var/lib/apt/lists/*
Without these, your canvas-rendered text will use Chrome’s fallback fonts, which produce a unique pixel pattern that says “Linux container, default font set.” Fingerprinters know this pattern and treat it as a high-confidence bot signal.
Real-world detection: what does it look like in logs
When canvas fingerprinting catches you, you typically see one of these patterns:
- HTTP 403 returned within 200ms of the first page load, before any scraping has happened. The fingerprint check ran on the landing page.
- A challenge page (Cloudflare, DataDome) that displays for a few seconds before redirecting back. The challenge is checking your canvas hash against a known-bot list.
- Increasing block rate as your scraper runs, even though early requests succeeded. The site collected your fingerprint, classified it as bot, and started blocking after a sample threshold.
- Cookie-based blocks: a cookie set during the fingerprint check carries a “this device is a bot” flag, and subsequent requests honor it even if you fix the fingerprint.
For the cookie case, clear cookies between contexts in Playwright. Each new browser context has a fresh cookie jar.
Operational checklist
For production scrapers facing canvas fingerprinting in 2026:
- Use patchright or rebrowser-playwright as your default Chromium driver
- Verify against browserleaks.com/canvas as part of your CI
- Install a representative font set in your container
- Rotate browser contexts between scrape jobs to get fresh canvas seeds
- Pair with WebGL fingerprinting bypass (separate but related)
- Pair with audio fingerprinting bypass for sites that combine all three
- Use clean residential or mobile proxies; even perfect canvas does not survive on dirty datacenter IPs
- Monitor for canvas hash drift after Chrome updates; the noise pattern can change
For the WebGL counterpart, see WebGL fingerprinting: bypass and modern defenses.
Common questions
Q: does canvas fingerprinting work in headless Chrome with no GPU?
Yes, and worse for scrapers. Without a GPU, Chrome falls back to software rendering which produces a distinctive software-rasterizer fingerprint. Many fingerprinters explicitly check for this and treat software-rasterized canvases as a bot flag. Run with the --use-gl=swiftshader flag plus VK_ICD_FILENAMES configured if you need GPU emulation.
Q: can I use a single fixed canvas hash for all my scrapers?
You can, but you should not. Vendors maintain databases of known scraper hashes and add new ones constantly. A fixed hash that works today gets added to a deny list within weeks. The right pattern is per-context noise that rotates the hash per session.
Q: what is the relationship between canvas fingerprinting and WebGL fingerprinting?
Both extract pixel data from a canvas, but WebGL renders 3D scenes via the GPU and produces a different surface. A target might check both independently, so bypass both. Patchright handles both in one package.
Q: do mobile browsers have canvas fingerprinting too?
Yes, identically. Safari iOS and Chrome Android both expose toDataURL and getImageData. The fingerprint differs from desktop because of different GPUs and font sets, which is itself a useful signal for vendors who want to verify “this device claims to be mobile, does its canvas match a mobile device?”
Q: how often do canvas fingerprints need to change to avoid detection?
Per scraping session at minimum. Within a session (single page load and a few subsequent requests), the fingerprint should be stable so you do not flag yourself as “device that changes its hardware mid-visit.” Across sessions, fresh contexts give you fresh hashes.
Common pitfalls in production canvas spoofing
The first failure is noise that accidentally produces a uniform-distribution hash. If your XOR-shift seed and modulo math result in noise values that average to zero across the canvas, the rendered output is statistically identical to the unmodified canvas. DataDome’s canvas check computes a histogram of pixel deltas and flags exact-zero-mean distributions as “noise injection detected.” Bias your noise toward a slight positive offset (for example (xorshift(key) % 7) - 2 instead of - 3) so the mean is non-zero. Verify by comparing your canvas histogram against a real Chrome run on the same target page.
The second pitfall is canvas re-creation between calls. Some bypass scripts apply noise inside toDataURL but forget that fingerprinters often draw to multiple canvases per page (one for text, one for shapes, one for emoji rendering). If your noise injection is tied to a single seed reused across canvases, the per-canvas hashes correlate in a way that real GPUs would not produce. The fix is a per-canvas seed derived from the canvas dimensions plus an instance counter, stored on a WeakMap keyed by canvas element. This keeps each canvas independently noisy while staying deterministic within a session.
The third pitfall is OffscreenCanvas. Chrome 124 supports OffscreenCanvas.transferToImageBitmap() and OffscreenCanvas.convertToBlob(), both of which return pixel data outside the main canvas APIs. Most bypass scripts hook only HTMLCanvasElement.prototype methods and miss the OffscreenCanvas equivalents. Patch both OffscreenCanvas.prototype.transferToImageBitmap and OffscreenCanvas.prototype.convertToBlob with the same noise logic. Test by running a fingerprinter that uses OffscreenCanvas (Akamai’s modern fingerprint script does) and verify the OffscreenCanvas-derived hash differs across sessions, not just the HTMLCanvasElement-derived one.
Real-world example: rotating canvas seeds across a worker pool
A scraper running 50 concurrent Playwright workers against a DataDome-protected travel site was flagged after 200 requests because every worker shared the same canvas seed (Math.random initialized at the same Docker image start time). The fix was to derive the seed from a combination of worker ID, request count, and proxy IP hash, ensuring each worker-session combination produced a unique fingerprint:
import hashlib
import os
async def make_canvas_init_script(worker_id: int, session_id: str, proxy_ip: str) -> str:
seed_basis = f"{worker_id}:{session_id}:{proxy_ip}:{os.urandom(8).hex()}"
seed = int(hashlib.sha256(seed_basis.encode()).hexdigest()[:8], 16)
return f"""
(() => {{
window.__canvasSeed = {seed};
// ... noise injection code from above ...
}})();
"""
# Inject before each new context
init_js = await make_canvas_init_script(worker_id=3, session_id="abc123", proxy_ip="203.0.113.42")
ctx = await browser.new_context()
await ctx.add_init_script(init_js)
After deploying this, the per-worker block rate dropped from 87 percent to 4 percent within 24 hours. The diversity of fingerprints across the worker pool was indistinguishable from 50 different real users, which was the goal. The lesson: canvas noise is necessary but not sufficient. The seed source matters as much as the noise algorithm.
Wrapping up
Canvas fingerprinting is old, and the bypass landscape is mature. The real question is not whether to defeat it but whether to roll your own stealth scripts or use a maintained library. For 2026, the answer for most teams is patchright. For the small minority who need extreme reliability, Browserbase or a similar hosted stealth browser service. Pair canvas defense with WebGL, audio, and behavioral defenses to cover the full surface, and read our TLS fingerprinting guide for the network-layer companion. Browse the anti-detect-browsers category on DRT for related tactics.