Fixing Tainted Canvas Cross-Origin Image Errors

Failure symptom:

Uncaught DOMException: Failed to execute 'getImageData' on 'CanvasRenderingContext2D':
The canvas has been tainted by cross-origin data.

Firefox words it differently for the same cause:

Uncaught DOMException: The operation is insecure.

The image draws perfectly. It is visible on screen at full quality. The exception fires only when you try to read the pixels back out.

Root Cause

Every canvas element carries an internal origin-clean flag, set to true when the element is created. Drawing a bitmap whose bytes the page is not allowed to read clears that flag, and every readback API then throws a SecurityError. An img element without a crossorigin attribute performs a no-CORS fetch, so the browser holds the decoded pixels for display but treats their contents as unreadable — the same boundary that produces an opaque fetch() result, applied to a bitmap. Without the flag, any page could load your private photo host or an internal dashboard screenshot into a canvas, read the pixels, and post them elsewhere. This page is one of the guides under Opaque Responses & no-cors Mode, which explains why cross-origin bytes routinely arrive without becoming readable.

The flag is one-way and per-canvas. Nothing you draw afterwards restores it.

The origin-clean flag across two draw paths The upper lane follows a fresh canvas through a draw from a CORS-approved image, leaving the origin-clean flag set so getImageData returns pixel data. The lower lane follows an identical canvas through a draw from an image fetched without a CORS grant, which clears the flag permanently and makes getImageData throw. Source carries a CORS grant Fresh canvas origin-clean = true drawImage() grant matched Canvas unchanged origin-clean = true getImageData() ImageData returned pixels are readable Source has no CORS grant Identical canvas origin-clean = true drawImage() no grant origin-clean = false permanent for this canvas getImageData() SecurityError thrown the drawing still displays Both canvases look identical on screen — the difference only exists at the moment of readback

Prerequisite State

Step-by-Step Fix

Step 1 — Find the draw that tainted the canvas

Wrap the readback in a probe that reports the last source drawn. Because the flag is set on the canvas rather than on the image, the exception fires far from the guilty line.

const ctx = canvas.getContext('2d');
const drawn = [];

function trackedDraw(image, x, y) {
  drawn.push(image.currentSrc || image.src);
  ctx.drawImage(image, x, y);
}

function readBack() {
  try {
    return ctx.getImageData(0, 0, canvas.width, canvas.height);
  } catch (err) {
    console.error('Canvas tainted. Sources drawn so far:', drawn);
    throw err;
  }
}

Any entry in drawn whose origin differs from the page origin is a candidate. In practice the culprit is nearly always a decorative sticker, a watermark, or an avatar loaded from a host nobody remembered was separate.

Step 2 — Set crossorigin before src

The attribute changes the request the browser makes, so it must be in place before the load begins. Assigning src first starts a no-CORS fetch that the later attribute cannot retroactively upgrade.

function loadCorsImage(url) {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.crossOrigin = 'anonymous';   // MUST come before src
    img.onload = () => resolve(img);
    img.onerror = () => reject(new Error(`Image load failed for ${url}`));
    img.src = url;
  });
}

const sticker = await loadCorsImage('https://media.example.org/stickers/star.png');
trackedDraw(sticker, 40, 40);

In markup the same rule applies through attribute order in the source:

<img crossorigin="anonymous" src="https://media.example.org/stickers/star.png" alt="Star">

anonymous means the request carries an Origin header but no cookies, which is what a public asset host should want. use-credentials sends cookies and demands a much stricter grant; the difference is worked through in Using the crossorigin Attribute on script and img.

Step 3 — Serve the grant from the media host

With crossorigin set, a response without Access-Control-Allow-Origin no longer merely taints the canvas — the image fails to load at all and onerror fires. The asset host must answer.

server {
    listen 443 ssl;
    server_name media.example.org;
    root /srv/media;

    location ~* \.(png|jpe?g|webp|avif|gif|svg)$ {
        add_header Access-Control-Allow-Origin "https://studio.example.org" always;
        add_header Vary Origin always;
        add_header Cross-Origin-Resource-Policy "cross-origin" always;
        add_header Cache-Control "public, max-age=604800" always;
    }
}

For an Express-served asset route the equivalent is a short middleware:

const ALLOWED = new Set(['https://studio.example.org', 'https://studio-beta.example.org']);

app.use('/stickers', (req, res, next) => {
  const origin = req.get('Origin');
  res.setHeader('Vary', 'Origin');
  if (origin && ALLOWED.has(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
  }
  next();
}, express.static('/srv/media/stickers'));

Five layers sit between the attribute you just added and the header you just deployed, and each of them can quietly undo the other.

Every hop an image grant has to survive A left-to-right chain of five layers carries an image request from the studio page to the media origin. A note under each layer names what it contributes to or takes away from the CORS grant, and a closing band explains that any single stripping layer reproduces the original error. studio page and canvas the img element browser HTTP cache CDN edge node media origin or bucket picks the request mode via crossorigin adds an Origin header in CORS mode only keys on the URL unless Vary: Origin is sent can strip or cache the grant header must emit the grant and Vary: Origin A grant that any one layer removes produces exactly the same SecurityError as a server that never sent one. Verify at the origin with curl first, then at the edge, then in the browser with the cache disabled.

Step 4 — Defeat the cached non-CORS copy

This is the step that turns a five-minute fix into an afternoon. If the browser already holds a cached copy of the image from a plain img tag, that stored response has no Access-Control-Allow-Origin. The new CORS-mode request matches the same cache entry, gets the grant-less copy back, and the canvas taints again — from a server that is now configured correctly.

How a cached non-CORS image defeats a correct server Four browser actions run left to right, from a first plain image load to a later crossorigin load and the fix. Underneath each step a chip records what the HTTP cache holds at that moment, showing a single grant-less entry being replayed until Vary Origin splits it into two. What the page does 1. Gallery page loads the sticker in a plain <img src> No Origin header is sent with the request. 2. You add the crossorigin attribute and deploy the correct headers on the media host. 3. Editor reloads and requests the sticker in CORS mode. The cache answers before the network. 4. Media host adds Vary: Origin so the two request shapes stop sharing one cache entry. What the HTTP cache holds one entry, stored without any grant same entry, still fresh and grant-less replayed: no grant, so the canvas taints two entries, one per request shape Step 3 fails on a correctly configured server — the cache, not the origin, is answering the request

Three things clear it. Send Vary: Origin on the asset (already present in the configuration above) so CORS and non-CORS loads occupy separate entries. Tick Disable cache in the DevTools Network panel while testing. And, for a one-off unblock in production, bump the asset URL — a content hash in the filename is the durable version of that. The general rule and its cache-poisoning consequences are covered in How to Fix Missing Vary: Origin Header Breaking CORS Cache Segmentation.

Step 5 — Fall back to a same-origin proxy when you cannot change the host

Third-party image hosts sometimes refuse to send CORS headers at all. Route those through your own origin, where the response becomes same-origin and the flag is never touched:

app.get('/proxy/image', async (req, res) => {
  const target = new URL(req.query.url);
  const ALLOWED_HOSTS = new Set(['images.partner.example', 'cdn.partner.example']);
  if (!ALLOWED_HOSTS.has(target.hostname)) {
    return res.status(400).send('Host not allowed');
  }
  const upstream = await fetch(target, { redirect: 'error' });
  if (!upstream.ok) return res.sendStatus(502);
  res.setHeader('Content-Type', upstream.headers.get('content-type') || 'image/png');
  res.setHeader('Cache-Control', 'public, max-age=86400');
  res.send(Buffer.from(await upstream.arrayBuffer()));
});

The host allowlist is not optional. An open image proxy is a server-side request forgery primitive that will happily fetch http://169.254.169.254/ for whoever asks.

A lighter alternative, when the asset host does send the grant, is to skip the img element entirely and fetch the bytes yourself. A blob: URL derived from a readable response is same-origin by construction, so the canvas never learns where the pixels came from:

const res = await fetch('https://media.example.org/stickers/star.png');
if (!res.ok) throw new Error(`Sticker fetch failed: ${res.status}`);
const bitmap = await createImageBitmap(await res.blob());
ctx.drawImage(bitmap, 40, 40);   // canvas stays origin-clean

The same rules apply beyond 2D canvas. A video element without crossorigin taints any canvas you draw a frame into, and WebGL refuses to accept a cross-origin texture that has no grant at all rather than tainting quietly. Fixing the header once at the media host clears every one of those paths.

Verification

curl the asset as the browser would, and confirm the grant is on the response:

curl -sI https://media.example.org/stickers/star.png \
  -H 'Origin: https://studio.example.org' | grep -i 'access-control-allow-origin\|vary'

Expected: access-control-allow-origin: https://studio.example.org and vary: Origin.

DevTools check: open the Network panel with Disable cache ticked, reload, and select the image row. The request headers must include Origin, and the response headers must include Access-Control-Allow-Origin. Then confirm the canvas reads back:

const ctx = document.querySelector('canvas').getContext('2d');
try {
  ctx.getImageData(0, 0, 1, 1);
  console.log('origin-clean: canvas is readable');
} catch {
  console.log('origin-clean: canvas is still tainted');
}

Security Boundary Note

Do not blanket the whole media host with Access-Control-Allow-Origin: * to make the error go away. Public stickers and marketing art are fine; user uploads, signed download URLs, and anything served behind a session are not. A wildcard on a path that also serves private media lets any page on the internet read those bitmaps pixel by pixel through exactly the canvas API you are trying to unblock. Scope the header to the public asset paths and name your own origins explicitly — Wildcard CORS Risks and Safe Origin Allowlisting sets out the trade-off in full.

Common Mistakes

Issue Technical impact Mitigation
Assigning img.src before img.crossOrigin The load has already started in no-CORS mode, so the attribute is ignored and the canvas taints Set crossOrigin on the element first, then assign src
Adding the attribute without configuring the asset host The image now fails to load entirely and onerror fires — a visible regression, not a silent one Deploy the Access-Control-Allow-Origin header before shipping the attribute
Testing with a warm HTTP cache A grant-less cached copy is replayed, so a correct server still produces a tainted canvas Send Vary: Origin, and test with Disable cache enabled
Reusing the same canvas for trusted and untrusted sources One untrusted draw poisons every later export from that surface Keep untrusted sources on a display-only canvas and export from a separate one

FAQ

Can I un-taint a canvas after drawing a cross-origin image?

No. The origin-clean flag is one-way for the lifetime of that canvas. Clearing the surface with clearRect, resizing it, or drawing over the offending region does not restore it. Create a fresh canvas element and draw only CORS-approved sources into it, or keep untrusted sources on a separate display-only canvas you never read back.

Why does the image still taint the canvas after I added crossorigin=“anonymous”?

Usually the browser replayed a cached copy that was originally fetched without CORS and therefore carries no Access-Control-Allow-Origin header, or the attribute was assigned after the src property so the load had already started. Send Vary: Origin on the image, set crossOrigin before src, and reload with the cache disabled to confirm which of the two applies.

Does a tainted canvas affect toDataURL and toBlob as well as getImageData?

Yes. Every readback path is gated by the same origin-clean flag: getImageData, toDataURL, toBlob, captureStream, and createImageBitmap when the result is read. Drawing, compositing, filtering and displaying all continue to work, which is why a tainted canvas looks perfectly healthy until the moment you try to export it.