Admin- Debugging CORS Issue When Serving Images from R2
Recently, I encountered a particularly tricky CORS-related bug while building the image intake + cropping pipeline for my SaaS project, NestButter. The setup seemed simple: upload to R2 using a presigned URL, then directly load the image from R2 via its public URL to perform further operations like cropping.
But something strange happened...
📝 Background: My Upload and Access Pipeline
I was using Cloudflare R2 to store images. My upload flow looked like this:
-
Upload
-
On the server, I generated a
PUTpresigned URL via R2 SDK -
On the client, I used that URL to
PUTthe image directly from the browser
-
-
Access
-
After upload, I would form the direct image URL like this:
const url = `https://user-imgs.nestbutter.com/store/${sha}.webp`; -
I would then load this image via
<img>or inside a cropping modal vianew Image()
-
Seems reasonable, right?
❌ The Problem: src is Correct, But Image Fails to Load in Modal
After uploading, I immediately attempted to open the image in a cropping modal. The modal uses this logic:
const im = new Image();
im.crossOrigin = "anonymous";
im.src = url;
Surprisingly:
-
srcwas correct (I logged it) -
imgin the UI also showed the image fine -
But when
Image()tried to load it in code, I got:
Access to image at 'https://user-imgs.nestbutter.com/store/xxx.webp'
from origin 'http://localhost:3000' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
So the modal failed, defaulted to asking the user to pick a file again.
🕵️ Step-by-Step Debugging
1. Check if src is really missing
I logged src in both the modal trigger and inside Image(). Confirmed: it's correct.
2. Verify CORS Policy in R2 Console
I had this at first:
Allowed Origins: http://localhost:3000, https://nest-butter.vercel.app
Wrong! R2 does not support comma-separated origins.
3. Fixed to split into two rows:
| http://localhost:3000 | GET, HEAD, PUT | * |
| https://nest-butter.vercel.app | GET, HEAD, PUT | * |
Still didn't work.
4. Inspect image headers
I opened the image in browser and checked the Response Headers. To my surprise:
-
No
Access-Control-Allow-Origin -
Cache status:
Cf-Cache-Status: HIT
Then it clicked.
⚡ Root Cause: Cloudflare Cached a Version Without CORS
Even though I updated the CORS config on the bucket, Cloudflare had already cached an earlier version of the response without those headers.
Hence:
-
The header was not present
-
The browser blocked access
-
Image()onerrortriggered
🔧 Solution: Use a Cloudflare Worker to Add CORS
Instead of depending on R2's CORS config (which can be silently bypassed due to CDN caching), I deployed a simple Cloudflare Worker as a proxy layer:
Worker Code:
export default {
async fetch(req) {
const url = new URL(req.url);
const key = url.pathname.replace("/proxy/", "store/");
const r2Resp = await fetch(`https://user-imgs.nestbutter.com/${key}`);
const headers = new Headers(r2Resp.headers);
headers.set("Access-Control-Allow-Origin", "*");
headers.set("Access-Control-Allow-Methods", "GET, HEAD");
headers.set("Access-Control-Allow-Headers", "*");
headers.set("Cache-Control", "public, max-age=31536000");
return new Response(r2Resp.body, {
status: r2Resp.status,
headers,
});
}
};
CDN URL before:
https://user-imgs.nestbutter.com/store/abc123.webp
After (Proxy URL):
https://img.nestbutter.com/proxy/abc123.webp
In my app, I simply added:
function cdnProxy(storePath: string) {
return storePath.replace(
"https://user-imgs.nestbutter.com/store/",
"https://img.nestbutter.com/proxy/"
);
}
✅ Final Result
-
Uploaded images instantly available for
<img>and<Image> -
No more CORS issues
-
Worker gives me full control over headers + future CDN behaviors
-
Don't have to rely on R2's flaky built-in CORS
☑️ Takeaways
-
Don't trust Cloudflare R2's CORS config if you're also using Cloudflare caching
-
Never test CORS via address-bar opening; always test via JS fetch / Image
-
Use Worker proxy for full control: clean, flexible, and safe
-
CDN caching can silently cause stale CORS headers to persist
Hope this helps others fighting with presigned URLs + CORS + R2 + Cloudflare cache madness. Let me know if you'd like a full wrangler deployment config!
Stay sharp ⚡

浙公网安备 33010602011771号