Uploading to Cloudflare R2 with Presigned URLs
R2 speaks the S3 API, so the familiar pattern works with two changes: point @aws-sdk/client-s3 at https://<ACCOUNT_ID>.r2.cloudflarestorage.com with region: "auto" and an R2 API token’s access key pair, and presign PutObjectCommand exactly as you would for S3. Configure CORS on the R2 bucket (dashboard or wrangler r2 bucket cors), keep the signed Content-Type identical to what the browser sends, and do not rely on S3 features R2 lacks — POST policies with content-length-range, object ACLs, and S3 event notifications. For size limits, sign Content-Length; for events, use R2 event notifications to a Cloudflare Queue.
R2’s appeal for media uploads is simple: no egress fees. Files users upload and then stream or download many times cost storage and operations, not bandwidth. The upload path itself is the same presigned PUT covered in S3 presigned URL workflows, with R2-specific edges. This page belongs to direct-to-cloud upload patterns in backend validation and cloud storage architecture. For comparing providers, see S3 vs GCS vs Azure Blob for media uploads.
When to use this approach
- You serve uploaded media publicly or to many users and egress dominates your storage bill.
- Your app already runs on Cloudflare Workers, or you want an S3-compatible store without AWS.
- Your upload code uses presigned PUTs or multipart uploads rather than POST policies.
Prerequisites
- A Cloudflare account with R2 enabled and a bucket (
media-uploads). - An R2 API token with Object Read & Write scoped to that bucket — it yields an Access Key ID and Secret Access Key.
- Node 20+ with
@aws-sdk/client-s3and@aws-sdk/s3-request-presigner3.600+, or a Worker withaws4fetch. - Your account ID (dashboard sidebar) for the endpoint URL.
How the pieces connect
Implementation
Server-side signing (Node or any runtime with the AWS SDK):
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { randomUUID } from "node:crypto";
const r2 = 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!,
},
});
const ALLOWED = new Set(["image/jpeg", "image/png", "image/webp", "video/mp4", "video/quicktime"]);
const MAX_BYTES = 500 * 1024 * 1024;
export async function createUpload(userId: string, contentType: string, size: number) {
if (!ALLOWED.has(contentType)) throw new Error("type not allowed");
if (!Number.isInteger(size) || size <= 0 || size > MAX_BYTES) throw new Error("size not allowed");
const key = `uploads/${userId}/${randomUUID()}`;
const url = await getSignedUrl(r2, new PutObjectCommand({
Bucket: "media-uploads",
Key: key,
ContentType: contentType,
ContentLength: size, // signed: a different body size fails with 403
Metadata: { "uploaded-by": userId }, // sent as x-amz-meta-uploaded-by; must be sent by the client
}), { expiresIn: 600, signableHeaders: new Set(["content-type", "content-length"]) });
return { key, url, headers: { "Content-Type": contentType, "x-amz-meta-uploaded-by": userId } };
}
In the browser:
async function uploadToR2(file: File) {
const res = await fetch("/api/uploads", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contentType: file.type, size: file.size }),
});
const { url, headers, key } = await res.json();
const put = await fetch(url, { method: "PUT", headers, body: file });
if (!put.ok) throw new Error(`R2 upload failed: ${put.status} ${await put.text()}`);
return key;
}
Bucket CORS, as JSON for wrangler r2 bucket cors set media-uploads --file cors.json:
{
"rules": [
{
"allowed": {
"origins": ["https://app.example.com"],
"methods": ["PUT", "GET", "HEAD"],
"headers": ["content-type", "x-amz-meta-uploaded-by"]
},
"exposeHeaders": ["ETag"],
"maxAgeSeconds": 3600
}
]
}
Line-by-line on the decisions that matter
region: "auto". R2 has no regions in the S3 sense;autois the documented value and is what goes into the signature’s credential scope.us-east-1also works for compatibility, butautoavoids confusion.- Account-scoped endpoint. Every bucket in an account shares
<ACCOUNT_ID>.r2.cloudflarestorage.com. Use path-style addressing (the SDK does so automatically for custom endpoints) — virtual-hosted style works too, but path style avoids TLS certificate surprises with bucket names containing dots. - Signing
Content-Length. R2 does not support POST policy uploads, socontent-length-rangeis not available. Signing an exact length is the equivalent size limit: the browser cannot send a different number of bytes with that URL. - Metadata as a signed header.
x-amz-meta-*values in the command become required headers; the browser must send them exactly, and they must be allowed in CORS. Use metadata for ownership and provenance, never for anything the client could lie about unsigned. exposeHeaders: ["ETag"]. Needed if you use multipart uploads from the browser, where each part’s ETag must be read to complete the upload.
Features that differ from S3
Reacting to uploads with Queues
Enable an event notification on the bucket for object-create and point it at a Queue: wrangler r2 bucket notification create media-uploads --event-type object-create --queue upload-events --prefix uploads/. A consumer Worker receives batches of messages with the bucket, key, size and ETag, and does what an S3 event handler would: confirm the upload record, kick off validation and processing. The pattern — events as the trigger, idempotent consumer — is the same as in upload completion events.
Consumers inside Workers can read the object through the R2 binding (env.MEDIA.get(key)), which avoids signing entirely and is faster than going through the S3 endpoint. Heavy processing such as transcoding still belongs in a container or external service; Workers are well suited to validation of headers, metadata writes and routing.
Signing inside a Worker
If your API is a Worker, you can skip the AWS SDK and sign with aws4fetch, which is small and uses Web Crypto:
import { AwsClient } from "aws4fetch";
export default {
async fetch(req: Request, env: Env) {
const { contentType, size } = await req.json<{ contentType: string; size: number }>();
const client = new AwsClient({ accessKeyId: env.R2_KEY_ID, secretAccessKey: env.R2_SECRET, service: "s3", region: "auto" });
const key = `uploads/${crypto.randomUUID()}`;
const url = new URL(`https://${env.ACCOUNT_ID}.r2.cloudflarestorage.com/media-uploads/${key}`);
url.searchParams.set("X-Amz-Expires", "600");
const signed = await client.sign(new Request(url, {
method: "PUT", headers: { "Content-Type": contentType, "Content-Length": String(size) },
}), { aws: { signQuery: true, allHeaders: true } });
return Response.json({ key, url: signed.url, headers: { "Content-Type": contentType } });
},
};
The browser code is unchanged. Keep the R2 token as a Worker secret, never a plain variable.
Serving what was uploaded
Uploads are only half of the reason to pick R2. For downloads, choose between three routes by how private the media is. Public media — product images, published videos — can be served from a custom domain attached to the bucket, which puts Cloudflare’s cache in front of it and makes repeat downloads free of both egress and most read operations. Private media needs either presigned GET URLs from the same S3 client, with short expiry, or a Worker that checks the user’s session and streams the object from the R2 binding. The Worker route costs a Worker invocation per request but lets you enforce permissions, add range support and set headers such as Content-Disposition centrally.
Avoid exposing the r2.dev development URL in production. It is rate-limited, cannot use Cloudflare cache rules, and makes the whole bucket publicly listable by key. Enable it only for quick testing, and disable it again before real users arrive.
Configuration gotchas
SignatureDoesNotMatch only from the browser. The browser sent a header you signed with a different value, or did not send a signed metadata header. Compare the signed headers list in the URL (X-Amz-SignedHeaders) with what DevTools shows in the request.
CORS errors even though the rule looks right. R2 CORS matches header names in lowercase and requires every non-simple request header to be listed. x-amz-meta-* and content-type must both appear; wildcards in headers are accepted but make debugging harder.
Uploads through the r2.dev public URL fail. The r2.dev subdomain and custom domains serve reads; uploads must go to the S3 endpoint. Use the custom domain for downloads only.
Multipart completion fails with InvalidPart. R2 requires all parts except the last to be the same size. Browser uploaders that adapt chunk size mid-upload must keep it constant for R2.
Verification
# Sign a URL and PUT a file from the command line with the exact headers.
node -e 'import("./r2.js").then(async m=>console.log(JSON.stringify(await m.createUpload("u1","image/png",'$(stat -c%s test.png)'))))' > up.json
curl -sS -X PUT "$(jq -r .url up.json)" -H "Content-Type: image/png" -H "x-amz-meta-uploaded-by: u1" --data-binary @test.png -w '%{http_code}\n'
# 200
# Wrong size is rejected
head -c 10 test.png | curl -sS -X PUT "$(jq -r .url up.json)" -H "Content-Type: image/png" -H "x-amz-meta-uploaded-by: u1" --data-binary @- -o /dev/null -w '%{http_code}\n'
# 403
Frequently Asked Questions
Is R2 faster or slower than S3 for uploads?
Comparable in most regions; uploads go to Cloudflare’s nearest location and are stored in the bucket’s region. The large difference is in download cost, not upload speed.
Can I use the same code for S3 and R2?
Yes, if you avoid the features in the table above. Make the endpoint, region and credentials configuration, and test both in CI against MinIO or the real services.
Should I use a jurisdiction-restricted bucket?
If you have data-residency requirements, R2 supports EU jurisdiction buckets with a different endpoint (<ACCOUNT_ID>.eu.r2.cloudflarestorage.com). Signing works the same way.