Tainted Canvas: CORS, toDataURL and Screenshots

A tainted canvas drew cross-origin pixels without CORS, so the page cannot read it. Why toDataURL fails, how to fix it, and why screenshots still work.

By Simon Chiu · Updated

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 crossOrigin after src. 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: Origin on the image server.
  • Access-Control-Allow-Origin: * with use-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.

To capture canvas charts without touching their CORS setup, create a free account and use element capture.

Frequently asked questions

How do I fix 'Tainted canvases may not be exported'?
Load the image with crossOrigin = 'anonymous' and make the image server send Access-Control-Allow-Origin. Set crossOrigin before src. If you cannot change the server, proxy the image through your own origin.
Why does html2canvas show blank images?
html2canvas redraws the page onto a canvas from inside the page, so it is bound by the same CORS rules. Cross-origin images without CORS headers either taint the canvas or are skipped.
Can a headless browser screenshot a tainted canvas?
Yes. A browser screenshot reads pixels from the rendering engine, not through the page's canvas API, so the tainted flag does not apply. In our test, both Puppeteer and Playwright captured a tainted canvas.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.