The image loads, but canvas export fails: fix image CORS
Debug tainted canvas exports with a two-origin example. Check crossorigin, response headers, redirects and cached images without sending files through a public proxy.
The image appears on the page. You draw it into a canvas. Then toDataURL() throws a SecurityError.
Displaying an image from another origin does not automatically give your page permission to read its pixels. The browser keeps those operations separate. MDN’s canvas CORS guide explains the permission required for export.
The fix has two parts: request the image in CORS mode and have its server allow your origin. Changing only the JavaScript leaves half the work undone.
Identify the two origins
An origin includes the scheme, hostname and port. These are different origins:
Page: http://127.0.0.1:8787
Image: http://127.0.0.1:8788/image.png
The hostname matches, but the ports do not. A production page and its image CDN usually differ by hostname instead.
Write down the page origin and the final image URL before editing anything. In DevTools, keep the Network panel open and select the image request. A request that redirects to another host must be checked through to its final response.
Compare three cases
| Image request | Server permission | Expected result |
|---|---|---|
| Ordinary image request | No CORS header | Image can display, but drawing it taints the canvas and blocks export |
crossOrigin = 'anonymous' |
No matching CORS header | The image load fails |
crossOrigin = 'anonymous' |
Header allows the page origin | The image can load and be exported from canvas |
The table describes the browser security model. Error wording differs between browsers. Other restrictions, including a page’s Content Security Policy, can block a request before it reaches these cases.
Download the dependency-free two-origin demonstration, save it as cors-demo.mjs and run:
node cors-demo.mjs
Open http://127.0.0.1:8787 and press Run all three cases. The page uses the same one-pixel PNG for each case and displays the result below the button. Both servers bind only to loopback. Stop them with Ctrl+C.
The script’s --check mode verifies both HTTP responses, their headers and the embedded JavaScript syntax. Browser enforcement must be observed by opening the page. We have not presented those HTTP checks as a browser test.
Set the attribute before the source
const image = new Image();
image.crossOrigin = 'anonymous';
image.src = 'https://images.example.com/product.jpg';
await image.decode();
const canvas = document.createElement('canvas');
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
const context = canvas.getContext('2d');
if (!context) throw new Error('Canvas 2D is unavailable');
context.drawImage(image, 0, 0);
const blob = await new Promise((resolve, reject) => {
try {
canvas.toBlob(value => {
if (value) resolve(value);
else reject(new Error('The browser could not encode the canvas'));
}, 'image/png');
} catch (error) {
reject(error);
}
});
This is the export core. Call it from your application’s error-handled flow. Setting crossOrigin before src makes the intended request mode explicit before loading starts. toBlob() avoids building a large base64 string, but it does not bypass the canvas origin rules.
For markup, the equivalent attribute is crossorigin="anonymous". See the HTML image element specification for the request behavior.
Configure the image response
For the local demonstration, the allowed response includes:
Access-Control-Allow-Origin: http://127.0.0.1:8787
Use your actual page origin in production. For genuinely public, noncredentialed images, Access-Control-Allow-Origin: * can be appropriate. For private assets, validate the origin against an allowlist. If the response varies by Origin, include Vary: Origin and make sure your CDN respects it.
Do not reflect any incoming origin into a credentialed response. Read the CORS response-header guidance before changing private-image access.
A header check can help separate server configuration from browser code:
curl --location --dump-header - --output /dev/null \
--header 'Origin: https://shop.example.com' \
'https://images.example.com/product.jpg'
Curl does not enforce browser CORS. It shows the responses so you can inspect them. A successful download in curl proves reachability, not that canvas export will work.
When the fix appears not to work
Check these in order:
- Confirm that the request in DevTools actually contains the expected
Originheader. - Inspect every redirect and the final response. Check the final asset host’s configuration.
- Disable the browser cache while testing. An image previously loaded by ordinary markup may have a cached response from before the header change.
- Check a service worker if the response says it came from one.
- Load a fresh image in CORS mode and draw it onto a new canvas. Fixing the server does not clean pixels already drawn from an unauthorized source.
Do not use fetch(..., { mode: 'no-cors' }) as a workaround. It gives JavaScript an opaque response rather than readable image bytes. Do not send private images through an arbitrary public CORS proxy.
If you control neither the image server nor its headers, offer a local file picker or change the export design. Start the next debugging session with the actual request and response, not another onerror handler.