GuidesTAINTED CANVAS

Tainted canvases may not be exported: how to fix it

Chrome throws "Tainted canvases may not be exported" from toDataURL() and toBlob(), and Firefox and WebKit throw "The operation is insecure", when the canvas holds an image from another origin that was loaded without CORS. The fix has two parts: set img.crossOrigin = 'anonymous' before loading the image, and make the image server send Access-Control-Allow-Origin. We tested every case below in Chromium 153, Firefox 155 and WebKit 26.6 on 26 October 2026.

Why the canvas gets tainted

drawImage() never throws on a cross-origin image, and the picture shows on screen. Only reading pixels back or exporting fails.

The taint is permanent and spreads: a tainted canvas drawn into a second canvas taints it, and createImageBitmap() of a tainted image gives a tainted bitmap. WebGL refuses the same images in texImage2D(), while a CORS-approved image uploads fine. data: URL images never taint.

Canvas and image CORS behavior in Chromium 153, Firefox 155 and WebKit 26.6 with the page and the image on different origins, checked October 2026
ActionChromium 153Firefox 155WebKit 26.6
toDataURL(), toBlob() or getImageData() on a tainted canvasSecurityError. Exports: Tainted canvases may not be exported. getImageData: The canvas has been tainted by cross-origin data.SecurityError: The operation is insecure. (all three calls)SecurityError: The operation is insecure. getImageData also logs: Unable to get image data from canvas because the canvas has been tainted by cross-origin data.
WebGL texImage2D() with a tainted imageSecurityError: The image element contains cross-origin data, and may not be loaded.SecurityError: The operation is insecure. (software WebGL)SecurityError: The operation is insecure.
crossOrigin anonymous, server sends no Access-Control-Allow-OriginImage not loaded. Console: No 'Access-Control-Allow-Origin' header is present on the requested resource.Image not loaded. Console: CORS header 'Access-Control-Allow-Origin' missingImage not loaded. Console: Cannot load image ... due to access control checks.
img.decode() on that failed imageEncodingError: The source image cannot be decoded.EncodingError: Invalid encoded image data.EncodingError: Access control error.
crossOrigin use-credentials, server sends Access-Control-Allow-Origin: *Image not loaded: the header must not be the wildcard '*' when the request's credentials mode is 'include'Image not loaded: Credential is not supported if the CORS header 'Access-Control-Allow-Origin' is '*'Image not loaded: Cannot use wildcard in Access-Control-Allow-Origin when credentials flag is true.
Same URL loaded without crossOrigin first, then with it, no Vary: OriginFails: reuses the cached responseWorks: sends a new CORS requestWorks: requests the image again
fetch() of an image without Access-Control-Allow-OriginTypeError: Failed to fetchTypeError: NetworkError when attempting to fetch resource.TypeError: Load failed
fetch() with mode no-cors, then createImageBitmap()Opaque, status 0, then InvalidStateError: The source image could not be decoded.Opaque, status 0, then InvalidStateError: The image could not be decodedOpaque, status 0, then InvalidStateError: Cannot create an ImageBitmap from an empty buffer
file:// page drawing an image from the same folderTainted: Tainted canvases may not be exported.Not tainted, export worksTainted: The operation is insecure.

The fix: crossOrigin plus Access-Control-Allow-Origin

Both sides have to opt in:

  • Header but no crossOrigin: the server sends Access-Control-Allow-Origin, but the browser makes a plain request and the canvas is still tainted.
  • Both: img.crossOrigin = 'anonymous' plus the header. The canvas stays clean and every export works.
  • crossOrigin but no header: the CORS check fails, so the image does not load at all and fires its error event.

Use 'anonymous' for public images. 'use-credentials' sends cookies and needs your exact origin plus Access-Control-Allow-Credentials: true; with Access-Control-Allow-Origin: * it failed in all 3 engines. Setting crossOrigin right after src in the same block still worked, but set it before src.

Many hosts already send the header, so crossOrigin = 'anonymous' is the only change you need. On 26 October 2026 avatars.githubusercontent.com, images.unsplash.com, picsum.photos, placehold.co, dummyimage.com and cdn.jsdelivr.net sent *, and pbs.twimg.com echoed the origin; the logo images on www.google.com sent none. Check any URL with the CORS tester.

Load a CORS-enabled image with crossOrigin and export it
const img = new Image();
img.crossOrigin = 'anonymous';
img.src = 'https://picsum.photos/id/237/400/300';
await img.decode();

const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
canvas.getContext('2d').drawImage(img, 0, 0);

const png = canvas.toDataURL('image/png');
console.log(`${canvas.width}x${canvas.height}`, `${png.length} characters`);

The Chrome cache trap

Chromium can fail a CORS image load even when the server is set up correctly. If the page first loads a URL without crossOrigin, for example in a normal <img> tag, and the response has no Vary: Origin, a later crossOrigin = 'anonymous' load of the same URL fails. Chromium reuses the cached response, which has no Access-Control-Allow-Origin, and sends no new request.

We hit this with a public JPEG on Amazon S3 (the sentinel-cogs bucket). Without an Origin request header S3 returned no Access-Control-Allow-Origin and no Vary; with one it returned Access-Control-Allow-Origin: * and Vary: Origin. Chromium 153 failed the second load, while Firefox 155 and WebKit 26.6 requested the image again and exported fine. Cloudflare R2 also sends CORS headers only when the request has an Origin header.

Three fixes:

  1. Mark every tag

    put crossorigin="anonymous" on every <img> that loads the URL, so the first request is already a CORS request.

  2. Use a separate URL

    add a query string such as ?cors=1 to the copy you draw, so Chromium keeps a separate cache entry.

  3. Add Vary to every response

    if you control the server, send Vary: Origin on all image responses, and Chromium requests the image again.

CORS rules for S3, R2 and Google Cloud Storage

The Amazon S3 console accepts only JSON, and a rule for canvas images needs just AllowedOrigins, AllowedMethods with GET and HEAD, and MaxAgeSeconds. ExposeHeaders and AllowedHeaders are not needed for image loads.

Cloudflare R2 takes the same JSON in the dashboard (bucket, Settings, CORS Policy, Add CORS policy, JSON tab), with MaxAgeSeconds up to 86400. Wrangler uses a different shape, {"rules": [{"allowed": {"origins": [...], "methods": ["GET"]}}]}, applied with npx wrangler r2 bucket cors set BUCKET --file cors.json. Changes can take up to 30 seconds, and on a custom domain cached files keep the old headers until you purge the cache.

Google Cloud Storage uses origin, method, responseHeader and maxAgeSeconds, applied with gcloud storage buckets update gs://BUCKET --cors-file=cors.json.

Amazon S3 or Cloudflare R2 CORS rule for canvas images
[
  {
    "AllowedOrigins": ["https://app.example.com"],
    "AllowedMethods": ["GET", "HEAD"],
    "MaxAgeSeconds": 3600
  }
]

html2canvas: useCORS and allowTaint

html2canvas 1.4.1 behaved the same in all 3 engines. With the defaults (useCORS and allowTaint both false), cross-origin images are skipped without an error: the export works, but the image is missing. useCORS: true draws images whose server sends Access-Control-Allow-Origin and still skips the rest.

allowTaint: true draws the image and then the export throws SecurityError, so it is no fix when you need a PNG.

Images on servers you don't control

fetch() follows the same rules as images: without Access-Control-Allow-Origin it rejects, and mode: 'no-cors' returns an opaque response with status 0 and an empty body. For a host you don't control you need a server in between: your own backend or a proxy.

The free tier of proxy.cors.dev relays text only, up to 1 MiB, for GET and HEAD, with no signup and 600 requests per minute per IP. PNG and JPEG are not relayed: https://proxy.cors.dev/https://picsum.photos/200 and the www.google.com logo both returned 502 with X-Cors-Error: unsupported_response. SVG is text, so it works: go.dev's SVG logo sends no CORS header, but through the proxy and an object URL it exported fine in all 3 engines. Use the object URL route for SVG, because createImageBitmap() threw InvalidStateError on SVG blobs.

For PNG, JPEG and other binary images, the proxy route is a Pro feature. Pro costs $5 per month for 500,000 requests and relays responses up to 6 MiB. The key goes in the X-Cors-Key request header, so use fetch(), a blob and the canvas; a plain <img src> prefix cannot send it. The trial gives 1,000 requests over 7 days with no card.

Fetch an image without CORS headers through the proxy, then draw it
const target = 'https://go.dev/images/go-logo-blue.svg';

// Free tier: SVG and other text. PNG, JPEG and WebP need Pro and an X-Cors-Key header.
const response = await fetch(`https://proxy.cors.dev/${target}`, { credentials: 'omit' });
if (!response.ok) throw new Error(response.headers.get('X-Cors-Error') ?? `HTTP ${response.status}`);

const img = new Image();
img.src = URL.createObjectURL(await response.blob());
await img.decode();

const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
canvas.getContext('2d').drawImage(img, 0, 0);
URL.revokeObjectURL(img.src);

console.log(response.headers.get('Content-Type'), `${canvas.width}x${canvas.height}`, canvas.toDataURL().length);

Good questions.

What does "Tainted canvases may not be exported" mean?

The canvas holds an image from another origin that was loaded without CORS, so the browser blocks reading its pixels. Chromium 153 throws it from toDataURL() and toBlob(); Firefox 155 and WebKit 26.6 throw "The operation is insecure".

Why is my canvas still tainted when the server sends Access-Control-Allow-Origin?

The image also needs crossOrigin = 'anonymous', set before src; without it the browser makes a plain request and the header changes nothing. If Chromium still fails, see the cache trap above.

Why does my image stop loading after I add crossOrigin = 'anonymous'?

The server does not send Access-Control-Allow-Origin, so the CORS check fails and all 3 engines drop the image instead of tainting the canvas. Add the header, or load the image through a server you control.

Does fetch() avoid the tainted canvas error?

Only when the server sends Access-Control-Allow-Origin: then fetch(), a blob and createImageBitmap() give a clean canvas. Without the header fetch() rejects, and mode: 'no-cors' returns an empty opaque response.

How do I fix a tainted canvas in html2canvas?

Pass useCORS: true and make sure the image server sends Access-Control-Allow-Origin; leave allowTaint off, because it makes the export throw.

Can I export a canvas with images from a site I don't control?

Yes if the site sends Access-Control-Allow-Origin, as GitHub avatars, Unsplash and jsDelivr do; set crossOrigin = 'anonymous'. Otherwise route the image through a server: proxy.cors.dev relays SVG on the free tier and PNG or JPEG up to 6 MiB on Pro.