A tainted canvas is an HTML <canvas> that has drawn an image from another origin without CORS permission, after which the browser blocks any script from reading its pixels with toDataURL, toBlob or getImageData.
Chrome reports it as SecurityError: Failed to execute 'toDataURL' on 'HTMLCanvasElement': Tainted canvases may not be exported. It is the usual reason client-side "screenshot this element" code fails. This entry is part of the screenshot glossary.
What it means in plain terms
A page may show an image from any site. It may not read that image's pixels unless the other site agrees. Otherwise a malicious page could draw your bank's logged-in avatar or an intranet chart and send the pixels home. The canvas carries a flag: once foreign pixels land on it without permission, the flag is set for good and every read is refused.
How it works technically
page on origin A image on origin B ───────────────── ───────────────── img.src = "https://b/cat.png" ───────▶ 200 image/png (no Access-Control-Allow-Origin) ctx.drawImage(img) → canvas shows the cat, origin-clean flag = false canvas.toDataURL() → SecurityError page.screenshot() (CDP) → works: pixels come from the compositor, not the canvas API with img.crossOrigin = "anonymous" and B sending Access-Control-Allow-Origin: * ctx.drawImage(img) → origin-clean flag stays true canvas.toDataURL() → "data:image/png;base64,..."
The rule comes from the HTML standard's origin-clean flag. It applies to images, videos and other canvases drawn onto a canvas, and to WebGL textures, where a cross-origin image without CORS cannot be uploaded at all.
The permission check needs both sides. The page must ask with crossOrigin ('anonymous' or 'use-credentials') before setting src, and the server must answer with a matching Access-Control-Allow-Origin. See CORS.
The test: export fails, screenshot works
We built two local origins. Port 4201 serves a page that draws a PNG from port 4202 onto a canvas, then tries toDataURL(). Port 4202 serves the PNG with or without Access-Control-Allow-Origin: *:
| Setup | toDataURL() in the page |
Element screenshot of the canvas |
|---|---|---|
No crossOrigin, no CORS header |
SecurityError ... Tainted canvases may not be exported. |
Saved, image visible |
crossOrigin = 'anonymous' + CORS header |
export ok |
Saved, image visible |
Both Puppeteer and Playwright gave the same results. This is the practical point: if you need an image of a canvas you cannot fix, capture it from outside the page instead of exporting it from inside.
Puppeteer
import puppeteer from 'puppeteer'; const url = process.argv[2] ?? 'http://localhost:4201/'; const browser = await puppeteer.launch(); try { const page = await browser.newPage(); await page.goto(url, { waitUntil: 'networkidle0' }); await page.waitForFunction(() => document.getElementById('out').textContent !== 'pending'); console.log('in-page export:', await page.$eval('#out', (el) => el.textContent)); const canvas = await page.$('#c'); await canvas.screenshot({ path: 'canvas-puppeteer.png' }); // pixels come from the compositor console.log('element screenshot saved'); } finally { await browser.close(); }
Playwright
import { chromium } from 'playwright'; const url = process.argv[2] ?? 'http://localhost:4201/'; const browser = await chromium.launch(); try { const page = await browser.newPage(); await page.goto(url); const out = page.locator('#out'); await out.filter({ hasNotText: 'pending' }).waitFor(); console.log('in-page export:', await out.textContent()); await page.locator('#c').screenshot({ path: 'canvas-playwright.png' }); console.log('element screenshot saved'); } finally { await browser.close(); }
The test page, reduced to its fixed form, is below. The fix is in the two marked lines:
<canvas id="c" width="400" height="240"></canvas><p id="out">pending</p> <script> const img = new Image(); img.crossOrigin = 'anonymous'; // 1. ask for CORS, before src img.onload = () => { const canvas = document.getElementById('c'); canvas.getContext('2d').drawImage(img, 0, 0, 400, 240); try { canvas.toDataURL(); out.textContent = 'export ok'; } catch (e) { out.textContent = e.name + ': ' + e.message; } }; img.src = 'http://127.0.0.1:4202/img.png'; // 2. this server must send Access-Control-Allow-Origin </script>
Tested on 2026-10-15 with Node 22.18.0, [email protected] (run with headless: 'shell') and [email protected] against the local two-origin server.
RenderScreenshot
The API takes its images with the browser's own screenshot function, like the scripts above, not with the page's canvas API. The origin-clean flag therefore does not stop the capture. To capture only the canvas, use element mode with capture.selector (GET: selector):
curl -G https://api.renderscreenshot.com/v1/screenshot \ -H "Authorization: Bearer $RS_API_KEY" \ --data-urlencode "url=https://example.com/chart" \ --data-urlencode "selector=#sales-chart canvas" \ --output chart.png
In the Node.js SDK this is .element('#sales-chart canvas'). The capture options page covers selector_required, which fails the request if the element is missing. If your app exports canvases client-side with a library like html2canvas, sending the page or its HTML to a server-side renderer avoids the CORS problem; see JavaScript screenshots of a web page. This request was not run for this page.
Common mistakes
- Setting
crossOriginaftersrc. The request has already started without CORS. Set it first. - A cached non-CORS copy. If the same image URL was loaded earlier without CORS, the browser may reuse that response. Add a query parameter or send
Vary: Originon the image server. Access-Control-Allow-Origin: *withuse-credentials. Credentialed requests need the exact origin, not*.- Forgetting video and other canvases. Drawing a cross-origin
<video>frame, or a canvas that is already tainted, taints the target canvas too. - Disabling web security in headless Chrome to "fix" it. It removes protections for every page in that browser. Capture from outside instead.
Related terms
- CORS: the permission system the canvas checks.
- HTML to image: server-side rendering that avoids the problem.
- WebGL in headless browsers: the same rule for textures.
To capture canvas charts without touching their CORS setup, create a free account and use element capture.