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.
Prerequisite State
- An editor at
https://studio.example.orgcomposes user artwork on a canvas and exports it withtoBlob(). - Source images are served from
https://media.example.org, a separate origin behind a CDN. - You control the response headers on the media host, either directly or through its bucket or CDN configuration.
- The images themselves are public assets; nothing in this fix is appropriate for private, per-user media without an allowlist.
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.
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.
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.