Fix: Cloudflare R2 Not Working — Bindings, S3 API Auth, CORS, Presigned URLs, and r2.dev Limits
Quick Answer
How to fix Cloudflare R2 errors — env.MY_BUCKET undefined in Workers, S3 SDK signature mismatch, multipart upload size limits, CORS on direct uploads, presigned URL generation, and r2.dev rate limits.
The Error
You try to write to R2 from a Worker and the binding is undefined:
export default {
async fetch(request: Request, env: Env) {
await env.MY_BUCKET.put("hello.txt", "world");
// TypeError: Cannot read properties of undefined (reading 'put')
},
};Or you use the S3 SDK against R2 and get a signature error:
SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided.Or a browser upload to a presigned URL is blocked by CORS:
Access to fetch at 'https://...r2.cloudflarestorage.com/...' from origin 'https://app.example.com'
has been blocked by CORS policyOr your production app stops serving images because the r2.dev URL got rate-limited:
429 Too Many Requests
Public access via r2.dev is intended for development.Why This Happens
R2 has two access patterns and most pain comes from mixing them up:
- Workers binding (
env.MY_BUCKET). First-class, zero-egress, no signing required. Configured inwrangler.toml. Only usable from inside a Worker. - S3-compatible API. Standard S3 SDK with R2 endpoint + access keys. Works from anywhere (browser, Node, EC2). Subject to S3 quirks (region must be
auto, path-style URLs, signature v4).
The CORS issue is separate — R2 needs CORS rules configured per bucket, exactly like S3.
r2.dev is the public dev URL. Cloudflare intentionally caps its throughput so you don’t ship it to production. For production, attach a custom domain to the bucket.
Fix 1: Bind R2 in wrangler.toml
For Worker access, declare the binding:
# wrangler.toml
name = "my-worker"
main = "src/worker.ts"
compatibility_date = "2026-05-01"
[[r2_buckets]]
binding = "MY_BUCKET"
bucket_name = "my-app-prod"
# For local dev with a separate bucket:
[[r2_buckets]]
binding = "MY_BUCKET"
bucket_name = "my-app-dev"
preview_bucket_name = "my-app-dev"Generate types:
wrangler typesThen in your Worker:
export interface Env {
MY_BUCKET: R2Bucket;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
if (request.method === "PUT") {
const url = new URL(request.url);
const key = url.pathname.slice(1);
await env.MY_BUCKET.put(key, request.body, {
httpMetadata: request.headers,
});
return new Response("ok");
}
return new Response("method not allowed", { status: 405 });
},
};The binding API is put, get, head, list, delete. No region, no signing — Cloudflare handles auth via the worker context.
Fix 2: Use the S3 SDK Correctly
For non-Worker clients (Node, browser, mobile), use the AWS SDK with R2’s endpoint and credentials:
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
const s3 = new S3Client({
region: "auto",
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
});
await s3.send(new PutObjectCommand({
Bucket: "my-app-prod",
Key: "uploads/file.bin",
Body: data,
ContentType: "application/octet-stream",
}));Three rules:
region: "auto"— R2 has no real regions. The SDK still demands one;"auto"is the magic value.- Account ID in the endpoint, not the bucket name. The bucket is in
Bucket, not in the host. - Use R2-issued access keys from the dashboard (R2 → Manage R2 API Tokens), not your Cloudflare API token.
Common Mistake: Using region: "us-east-1" or another real region. The signature is calculated against the region, so a mismatch produces SignatureDoesNotMatch errors.
Fix 3: Configure CORS on the Bucket
For browser uploads (presigned URL → PUT from the browser), R2 enforces CORS. Set rules in the dashboard or via the API:
[
{
"AllowedOrigins": ["https://app.example.com"],
"AllowedMethods": ["PUT", "GET", "HEAD"],
"AllowedHeaders": ["content-type", "content-length"],
"ExposeHeaders": ["etag"],
"MaxAgeSeconds": 3600
}
]Apply with the AWS CLI pointed at R2:
aws s3api put-bucket-cors \
--bucket my-app-prod \
--cors-configuration file://cors.json \
--endpoint-url https://$R2_ACCOUNT_ID.r2.cloudflarestorage.comTwo CORS gotchas specific to R2:
- Custom domains have their own CORS. Rules set on the bucket apply to S3 endpoints; if you serve via a custom domain (
assets.example.com), the domain inherits the bucket CORS but you can also tweak via Cloudflare Transform Rules. AllowedHeadersmust list every custom header the browser sends. Includingx-amz-*if your client uses SDK-style auth (rare with presigned URLs but possible).
Fix 4: Generate Presigned URLs From a Worker
The browser shouldn’t have R2 credentials. Generate presigned URLs from a Worker and hand them to the client:
import { AwsClient } from "aws4fetch";
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const { key } = await request.json<{ key: string }>();
const r2 = new AwsClient({
accessKeyId: env.R2_ACCESS_KEY_ID,
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
service: "s3",
region: "auto",
});
const url = new URL(
`https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com/${env.BUCKET}/${key}`,
);
url.searchParams.set("X-Amz-Expires", "3600");
const signed = await r2.sign(
new Request(url, { method: "PUT" }),
{ aws: { signQuery: true } },
);
return Response.json({ uploadUrl: signed.url });
},
};aws4fetch is a small, edge-friendly SigV4 implementation that runs in Workers. The full AWS SDK works in Workers but pulls in a lot of code — aws4fetch is leaner.
Client side:
const { uploadUrl } = await fetch("/api/presign", {
method: "POST",
body: JSON.stringify({ key: "uploads/file.bin" }),
}).then((r) => r.json());
await fetch(uploadUrl, {
method: "PUT",
body: file,
headers: { "content-type": file.type },
});Pro Tip: Sign with content-type and require the client to send the same one. Otherwise a client could substitute the content type, which matters for <script>/HTML/etc. served back from R2.
Fix 5: Multipart Uploads for Big Files
R2’s single-request PUT has a per-request size limit (currently 5 GB). For larger objects, use multipart uploads:
// From a Worker, using the binding:
const upload = await env.MY_BUCKET.createMultipartUpload("big-file.bin");
const part1 = await upload.uploadPart(1, slice1);
const part2 = await upload.uploadPart(2, slice2);
await upload.complete([part1, part2]);From the S3 SDK:
import { CreateMultipartUploadCommand, UploadPartCommand, CompleteMultipartUploadCommand } from "@aws-sdk/client-s3";
const { UploadId } = await s3.send(new CreateMultipartUploadCommand({
Bucket: "my-app-prod",
Key: "big.bin",
}));
const parts = [];
for (let i = 0; i < chunks.length; i++) {
const { ETag } = await s3.send(new UploadPartCommand({
Bucket: "my-app-prod",
Key: "big.bin",
UploadId,
PartNumber: i + 1,
Body: chunks[i],
}));
parts.push({ PartNumber: i + 1, ETag });
}
await s3.send(new CompleteMultipartUploadCommand({
Bucket: "my-app-prod",
Key: "big.bin",
UploadId,
MultipartUpload: { Parts: parts },
}));Each part (except the last) must be at least 5 MB. Smaller parts are wasted overhead.
Common Mistake: Forgetting to call complete (or abort on failure). Incomplete multipart uploads accumulate and count toward storage. Set a bucket lifecycle rule to abort incomplete uploads after 7 days.
Fix 6: Stop Using r2.dev in Production
r2.dev is the public dev URL Cloudflare provides per bucket. It’s intentionally rate-limited and has a banner warning. For production, attach a custom domain:
- Cloudflare dashboard → R2 → Your bucket → Settings → Public access → Connect domain.
- Pick a subdomain on a Cloudflare-managed zone (e.g.
assets.example.com). - R2 sets up the DNS automatically.
Once attached:
- Cloudflare’s CDN sits in front of R2 (free egress within Cloudflare).
- You can apply Cache Rules, Transform Rules, Workers — same as any Cloudflare-fronted asset.
- No
r2.devrate limits.
Note: Public access can be disabled per bucket. If you want to serve only signed URLs (no public reads), turn off public access and serve via a Worker that issues short-lived presigned URLs.
Fix 7: Listing and Pagination
list returns up to 1000 objects per call. Continue with the cursor:
let cursor: string | undefined;
const allKeys: string[] = [];
do {
const result = await env.MY_BUCKET.list({ prefix: "uploads/", cursor });
allKeys.push(...result.objects.map((o) => o.key));
cursor = result.truncated ? result.cursor : undefined;
} while (cursor);From the S3 SDK:
import { ListObjectsV2Command } from "@aws-sdk/client-s3";
let continuationToken: string | undefined;
const keys: string[] = [];
do {
const result = await s3.send(new ListObjectsV2Command({
Bucket: "my-app-prod",
Prefix: "uploads/",
ContinuationToken: continuationToken,
}));
keys.push(...(result.Contents ?? []).map((o) => o.Key!));
continuationToken = result.IsTruncated ? result.NextContinuationToken : undefined;
} while (continuationToken);For very large prefixes, list is expensive. Consider Workflows or queues to do it asynchronously, or maintain an index in D1.
Fix 8: Metadata and Content-Type
When puting from a Worker, set httpMetadata so reads come back with the right Content-Type:
await env.MY_BUCKET.put(key, file.stream(), {
httpMetadata: {
contentType: file.type,
cacheControl: "public, max-age=31536000, immutable",
},
customMetadata: {
uploadedBy: userId,
uploadedAt: new Date().toISOString(),
},
});httpMetadata is the limited set R2 returns as response headers on GET (Content-Type, Content-Disposition, Cache-Control, Content-Encoding, Content-Language).
customMetadata is your free-form key-value (returned as x-amz-meta-* headers via S3 API). Use it for app-specific tags.
Pro Tip: Set Cache-Control: immutable on hashed asset filenames (bundle-abc123.js). Cloudflare’s CDN respects it and serves cached responses without revalidating, which is a big speedup for static asset workloads.
Still Not Working?
A few less-obvious failures:
OperationAborted: object already existsonput— R2 doesn’t fail on existing keys by default. This error usually means you setonlyIforIf-None-Matchand the precondition wasn’t met. Drop them if you want overwrite semantics.- Listing returns no objects but the bucket has files. Check the
prefix. R2 list is prefix-based and case-sensitive —Uploads/≠uploads/. - Range requests don’t work. Set the
Rangeheader on the request and readresult.body.rangefor the served range. R2 supports it but the SDK needs to be told. X-Amz-Trace-Idnot propagating to logs. R2 doesn’t surface S3 request IDs the same way. For debugging, log your own request ID in the Worker and correlate with R2 dashboard metrics.- Cost surprises from operations. R2 charges per Class A and Class B operations. Bulk
listanddeleteruns add up — batch with Workers if possible. - Browser upload works locally, fails on Safari. Safari’s stricter CORS requires
ExposeHeadersto includeetagif your code reads the response’sETag. Add it to the bucket CORS config. - Public access turned on but URL 404s. New buckets take a few minutes for public DNS to propagate. Wait or retry.
- Custom domain serves R2 but old r2.dev URL also still works. That’s by design — disable r2.dev explicitly in the bucket settings if you don’t want it.
For related Cloudflare and object-storage issues, see Cloudflare D1 not working, Wrangler not working, AWS S3 access denied, and AWS S3 CORS error.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Cloudflare D1 Not Working — Binding Errors, Local vs Remote, Migrations, and Foreign Keys
How to fix Cloudflare D1 errors — D1_ERROR no such table, binding undefined, --local vs --remote drift, migrations not applied, prepared statement bind index, foreign keys not enforced, and concurrent writes.
Fix: Cloudflare Durable Objects Not Working — ID Strategy, Storage API, WebSocket Hibernation, Alarms
How to fix Cloudflare Durable Objects errors — idFromName vs newUniqueId, Storage transactions, blockConcurrencyWhile, WebSocket Hibernation API, alarms, migrations, and class binding setup.
Fix: Cloudflare Pages Not Working — Build Output, Functions Routing, _redirects, and Bindings
How to fix Cloudflare Pages errors — build output directory mismatch, Functions in /functions/, _redirects vs _headers, compatibility flags, env per branch, D1/R2/KV bindings, and Direct Upload alternatives.
Fix: Cloudflare Queues Not Working — Producer Binding, Consumer Worker, Batching, and Dead Letter
How to fix Cloudflare Queues errors — producer queue.send not delivering, consumer not invoking, ack/retry/DLQ patterns, batch size limits, max_retries, content type pitfalls, and local dev with wrangler.