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

  1. A Cloudflare account with R2 enabled and a bucket (media-uploads).
  2. An R2 API token with Object Read & Write scoped to that bucket — it yields an Access Key ID and Secret Access Key.
  3. Node 20+ with @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner 3.600+, or a Worker with aws4fetch.
  4. Your account ID (dashboard sidebar) for the endpoint URL.

How the pieces connect

Presigned upload flow to Cloudflare R2 The browser asks your API for an upload URL. The API signs a PUT for the R2 S3 endpoint using an R2 API token. The browser PUTs the file directly to R2. An R2 event notification sends a message to a Cloudflare Queue, and a consumer Worker records the upload. Downloads are served through a custom domain on the bucket with zero egress fees. S3-shaped upload, Cloudflare-shaped events browser your API signs with R2 token R2 bucket S3 endpoint event → Queue → Worker record the upload 1 request 2 URL 3 PUT bytes 4 notify The API never handles file bytes; R2 never needs your Cloudflare account credentials in the browser.
Steps 1–3 are identical to S3; step 4 uses Cloudflare Queues instead of S3 events.

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; auto is the documented value and is what goes into the signature’s credential scope. us-east-1 also works for compatibility, but auto avoids 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, so content-length-range is 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

S3 upload features and their R2 equivalents Presigned PUT and GET, multipart upload and checksums work on R2. POST policy uploads, object ACLs and S3 event notifications do not; the equivalents are signed Content-Length, bucket-level public access or custom domains, and R2 event notifications to Cloudflare Queues. Lifecycle rules exist with fewer options. What carries over and what needs another route S3 feature on R2 use instead presigned PUT / GET yes multipart upload yes parts must be equal size except last POST policy uploads no signed Content-Length on PUT object ACLs no custom domain or signed GET S3 event notifications no R2 event notifications → Queues lifecycle rules partial expiry, abort multipart, IA class Check the R2 S3 compatibility table before porting code that uses less common operations.
The core upload path is compatible; policy, permission and event features need Cloudflare-native replacements.

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.

Download routes for media stored in R2 Public media is served through a custom domain with Cloudflare cache. Private media uses short-lived presigned GET URLs, or a Worker that checks the session and streams from the R2 binding. The r2.dev URL is for testing only. Pick the read path by how private the file is custom domain public media edge cached cheapest per view presigned GET private, simple expiry in minutes no per-request logic Worker + binding private, checked session per request central headers The r2.dev URL is rate-limited and uncached — useful for testing, not for users.
All three routes avoid egress fees; they differ in caching and access control.

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.