R2 CORS Wildcards Don't Work Like S3 and It Will Break You
Cloudflare R2 CORS configuration looks identical to S3 on the surface. It is not, and the difference will silently kill your presigned upload flow.
I spent half a day last month debugging a presigned upload flow that worked perfectly against S3 for two years and broke the moment I pointed it at R2. The bucket was configured, credentials were valid, the presigned URLs generated fine. The browser just kept throwing CORS errors and I kept staring at a CORS config that looked correct.
It was correct - for S3. R2 handles wildcard origins differently, and the docs don't make this obvious.
The setup
I migrated a client's file upload pipeline to R2 earlier this year. They're an e-commerce shop, upload product images from the browser directly to storage using presigned PUT URLs. Classic pattern: PHP backend generates a short-lived presigned URL, JavaScript does a fetch() PUT directly to the bucket, nobody's bandwidth hits my server. Works great on S3. Costs are lower on R2 and the egress pricing was the whole reason we were moving.
The Laravel side generates presigned URLs using the AWS SDK (R2 is S3-compatible, so this works):
use Aws\S3\S3Client;
use Aws\S3\Presigner;
class StorageService
{
private S3Client $client;
public function __construct()
{
$this->client = new S3Client([
'region' => 'auto',
'endpoint' => env('R2_ENDPOINT'), // https://<account>.r2.cloudflarestorage.com
'credentials' => [
'key' => env('R2_ACCESS_KEY'),
'secret' => env('R2_SECRET_KEY'),
],
'version' => 'latest',
]);
}
public function presignedUploadUrl(string $key, string $mimeType, int $ttlSeconds = 300): string
{
$cmd = $this->client->getCommand('PutObject', [
'Bucket' => env('R2_BUCKET'),
'Key' => $key,
'ContentType' => $mimeType,
]);
$request = $this->client->createPresignedRequest($cmd, '+' . $ttlSeconds . ' seconds');
return (string) $request->getUri();
}
}
That part works fine. The presigned URL comes back, you hand it to the browser, the browser does its PUT. Except when CORS blocks it.
The wildcard problem
On S3, this CORS configuration is the "just make it work" starting point that everyone copies from Stack Overflow:
[
{
"AllowedHeaders": ["*"],
"AllowedMethods": ["GET", "PUT", "POST", "DELETE", "HEAD"],
"AllowedOrigins": ["*"],
"ExposeHeaders": []
}
]
On S3, AllowedOrigins: ["*"] works exactly how you'd expect. Any origin, preflight passes, upload goes through.
On R2, AllowedOrigins: ["*"] does not work for presigned URLs when the request includes certain headers - specifically Content-Type. And presigned PUT uploads almost always send Content-Type.
Here's the thing: when a browser sends a preflight OPTIONS request and your CORS rule matches via wildcard, R2 returns Access-Control-Allow-Origin: *. But when you include Content-Type in the actual request (which you must for a typed file upload), browsers treat that as a request that needs a specific origin reflected back, not a wildcard. R2 returns the wildcard anyway. Some browsers accept this, some don't. Chrome in my testing was inconsistent across versions, which made this extra fun to track down.
The actual fix is explicit: list your origins.
[
{
"AllowedHeaders": ["Content-Type", "Content-Length", "x-amz-date", "authorization"],
"AllowedMethods": ["GET", "PUT", "HEAD"],
"AllowedOrigins": [
"https://yourdomain.com",
"https://staging.yourdomain.com"
],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}
]
With a real origin listed, R2 reflects that specific origin back in Access-Control-Allow-Origin, the browser is happy, and the presigned PUT goes through.
You apply this via the R2 API or through the Cloudflare dashboard under your bucket's Settings tab. You can also do it programmatically with the S3 SDK:
public function applyCorsPolicy(): void
{
$this->client->putBucketCors([
'Bucket' => env('R2_BUCKET'),
'CORSConfiguration' => [
'CORSRules' => [
[
'AllowedHeaders' => [
'Content-Type',
'Content-Length',
'x-amz-date',
'x-amz-content-sha256',
'authorization',
],
'AllowedMethods' => ['GET', 'PUT', 'HEAD'],
'AllowedOrigins' => [
'https://yourdomain.com',
'https://staging.yourdomain.com',
],
'ExposeHeaders' => ['ETag'],
'MaxAgeSeconds' => 3600,
],
],
],
]);
}
I wire this into a console command so I can re-apply it without touching the dashboard:
php artisan storage:apply-cors
Simple, version-controllable, reproducible across environments.
The other gotchas
While I was in there debugging, I hit a few more rough edges worth knowing about.
MaxAgeSeconds is not optional in practice. S3 works fine without it. R2 seems to cache preflight responses inconsistently when it's missing. Set it explicitly. I use 3600 for production, 0 for local dev so I'm not fighting stale preflight caches while iterating.
ETag is not exposed by default. If you're confirming uploads by reading the ETag back from the PUT response (which is a clean way to verify the upload went through), you need ExposeHeaders: ["ETag"] explicitly. S3 exposes it without asking. R2 does not.
The Cloudflare dashboard CORS editor is JSON-only and validates loosely. I pasted in config with a trailing comma once and it accepted it silently but didn't apply the rule. If your CORS changes don't seem to be taking effect, delete and recreate the rule rather than editing in place.
R2's CORS errors in the Cloudflare logs are sparse. With S3, CloudWatch or the S3 server access logs at least tell you what origin was presented and what rule matched (or didn't). R2's logging at the storage layer is minimal. I ended up using curl to manually fire OPTIONS preflights and read the response headers to confirm what R2 was actually returning:
curl -X OPTIONS \
-H "Origin: https://yourdomain.com" \
-H "Access-Control-Request-Method: PUT" \
-H "Access-Control-Request-Headers: Content-Type" \
-I \
"https://<account>.r2.cloudflarestorage.com/<bucket>/<key>"
If Access-Control-Allow-Origin is missing from the response, your rule isn't matching. If it's present, you're good.
There is no per-object ACL. R2 has no concept of object-level ACLs. Everything is controlled at the bucket level plus what you put in the presigned URL. This is actually simpler than S3 in most cases - I used to forget to set ACL: public-read on uploads and spend ten minutes wondering why images weren't loading. R2 makes you think about public vs. private at the bucket configuration level, not the object level.
When I'd reach for R2
If you're moving a straightforward file storage workload - user uploads, generated assets, media files - R2 is genuinely good and the pricing math works in your favor, especially if you have any real egress volume. The S3 SDK compatibility means your existing PHP code mostly just works once you swap the endpoint and credentials.
I'd reach for R2 when:
- Egress costs on S3 are noticeable on your bill
- You don't need S3-specific features like S3 Object Lambda, S3 Select, or lifecycle transitions to Glacier
- You're already in the Cloudflare ecosystem (Workers, Pages, etc.) and want native R2 bindings
- You want a simpler ACL model and fewer footguns around accidentally public buckets
I'd stay on S3 when:
- You need fine-grained IAM policies and per-object ACLs
- You're using features that have no R2 equivalent (event notifications to Lambda, Intelligent-Tiering, etc.)
- Your team is already fluent in the full S3/IAM ecosystem and the cost difference doesn't justify the migration
- You need multi-region replication or compliance features that Cloudflare hasn't shipped yet
For the client I migrated, R2 was the right call. Egress from S3 was showing up on their AWS bill every month for no particularly good reason. R2's egress is free. The CORS headache cost me a few hours, but that's a one-time tax.
The bottom line
R2 is S3-compatible in the ways that matter for basic operations, but "compatible" doesn't mean "identical," and the CORS wildcard behavior is exactly the kind of subtle difference that bites you after a confident migration. Ditch the wildcard origin, list your actual domains, and fire that curl preflight before you declare victory. Twenty minutes of verification beats half a day of debugging in production.
Need help shipping something like this? Get in touch.