After this lesson you'll be able to generate a presigned R2 URL from a Worker so a browser can upload or download an object directly, without routing the bytes through your Worker, and serve objects publicly via a custom domain without misconfiguring access.
Every R2 object can be read or written two ways: through a Worker binding (env.BUCKET.put()/get()), or through R2's S3-compatible API reachable over plain HTTPS. The binding path is simplest, but it means every byte of a file passes through your Worker — fine for a 20 KB JSON blob, wasteful and slow for a 2 GB video. Presigned URLs solve this: your Worker generates a short-lived, signed URL that authorizes one specific operation on one specific object, and the browser talks to R2 directly. Your Worker's job shrinks to "decide who's allowed to upload what," not "proxy the upload."
A presigned URL is a normal R2 object URL with extra query parameters (an AWS Signature Version 4 signature, an expiry, and the credential that produced it) baked in. Anyone holding that URL can perform exactly the one operation it was signed for — GET, HEAD, PUT, or DELETE on one object — until it expires. R2 doesn't need to be contacted to create the URL; signing is pure local cryptography using your R2 API token's access key and secret, which is why generating one is fast and can happen inside a Worker's request handler without an extra round trip.
POST) isn't supported for presigning — only single-shot PUT.https://<ACCOUNT_ID>.r2.cloudflarestorage.com, addressed with region "auto". This is separate from the Worker binding; it needs an R2 API token (access key ID + secret access key), not the Env binding.The request lifecycle for a direct upload looks like this:
Browser Worker R2 (S3 endpoint)
│ 1. POST /sign-upload │ │
│───────────────────────►│ │
│ │ 2. sign PUT URL │
│ │ (SigV4, local, no │
│ │ network call to R2) │
│ 3. { url, key } │ │
│◄───────────────────────│ │
│ │
│ 4. PUT <url> (file bytes go straight to R2) │
│─────────────────────────────────────────────────►│
│ 5. 200 OK │
│◄─────────────────────────────────────────────────│
Step 4 never touches your Worker. That's the entire point: your Worker's CPU time and request count are spent once, on a cheap signing operation, no matter how large the file is.
Presigned URLs grant temporary, per-object access. Public buckets grant permanent, unauthenticated read access to everything in the bucket — the right shape for static assets (images, downloads, build artifacts) that anyone should be able to fetch anytime. R2 buckets are private by default; you opt in to public access per bucket, one of two ways:
r2.dev subdomain — a Cloudflare-managed URL you can flip on with one click, explicitly documented as rate-limited and intended for development/testing only, not production traffic.r2.dev.Public access, either way, serves individual objects by key — there's no directory listing at the bucket root, so an object is only reachable if something already knows (or can guess) its key.
R2 can emit events on object-create (put, copy, or completed multipart upload) and object-delete (explicit delete or lifecycle expiry) to a Cloudflare Queue, which a Worker consumer or external puller can process. This is what closes the loop on the direct-upload pattern above: since the upload bypassed your Worker, your Worker has no idea the object exists until it reads a notification off the queue — for example, to kick off virus scanning, thumbnail generation, or a database row update.
npx wrangler r2 bucket notification create my-uploads \
--event-type object-create \
--queue upload-processing \
--suffix ".jpg"
A Worker endpoint that signs a PUT URL for direct browser upload. This needs an R2 API token (create one in the dashboard under R2 → Manage API Tokens) stored as Worker secrets — wrangler secret put R2_ACCESS_KEY_ID and wrangler secret put R2_SECRET_ACCESS_KEY — plus the AWS SDK v3 packages (npm i @aws-sdk/client-s3 @aws-sdk/s3-request-presigner).
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
export interface Env {
ACCOUNT_ID: string;
R2_ACCESS_KEY_ID: string;
R2_SECRET_ACCESS_KEY: string;
}
export default {
async fetch(req: Request, env: Env): Promise<Response> {
const { filename, contentType } = await req.json<{
filename: string; contentType: string;
}>();
// TODO: authenticate the caller and validate filename/contentType
// before handing out a signing capability — see pitfall below.
const s3 = new S3Client({
region: "auto",
endpoint: `https://${env.ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: env.R2_ACCESS_KEY_ID,
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
},
});
const key = `uploads/${crypto.randomUUID()}-${filename}`;
const uploadUrl = await getSignedUrl(
s3,
new PutObjectCommand({
Bucket: "my-uploads",
Key: key,
ContentType: contentType,
}),
{ expiresIn: 300 } // 5 minutes — just long enough to start the PUT
);
return Response.json({ uploadUrl, key });
},
};
The browser then uploads the file straight to R2 — no Worker in the data path:
const { uploadUrl, key } = await fetch("/sign-upload", {
method: "POST",
body: JSON.stringify({ filename: file.name, contentType: file.type }),
}).then((r) => r.json());
await fetch(uploadUrl, {
method: "PUT",
headers: { "Content-Type": file.type },
body: file, // a File/Blob from an <input type="file">
});
console.log("Uploaded as", key);
Downloads work the same way with GetObjectCommand in place of PutObjectCommand — useful for gating access to otherwise-private objects (e.g., a paid download) without making the whole bucket public.
Presigned URL traffic and public bucket traffic are billed as ordinary R2 storage and operations — there's no separate line item for "presigned" requests, and R2 has no egress fees at all, which is a big part of why this pattern is attractive for large files:
| Item | Standard storage | Free tier (Standard) |
|---|---|---|
| Storage | $0.015/GB-month | 10 GB-month |
| Class A (writes: PUT, POST, list, copy, etc.) | $4.50/million requests | 1 million/month |
| Class B (reads: GET, HEAD, etc.) | $0.36/million requests | 10 million/month |
| Egress bandwidth | free, unlimited, any class | — |
A presigned PUT upload is one Class A operation; a presigned or public GET is one Class B operation, same as if the request had gone through a Worker binding. What you save by going direct isn't R2 charges — it's Worker request/CPU time and the wall-clock time of proxying large payloads through an isolate. Confirm current figures on the live pricing page below; this table is a snapshot.
GET URL that expires in minutes, so the link is useless if intercepted or shared later.expiresIn (the max is 7 days) for a sensitive object means anyone who intercepts it — via browser history, a referrer header, a logged URL, or a shared screenshot — has that access for the full window, with no way to revoke it early short of rotating the underlying R2 API token (which invalidates every URL signed with it). Default to the shortest expiry that makes sense for the workflow — minutes, not days — and sign per-object rather than trying to build one reusable URL for many keys. Second, public bucket access is opt-in per bucket, but it's easy to enable a bucket's r2.dev subdomain "just to test" and forget it's still live in production, or to add a custom domain for public serving without realizing the r2.dev subdomain is still separately enabled underneath it — Cloudflare's own docs warn to explicitly disable the r2.dev subdomain when you're relying on WAF or Access rules on a custom domain, since those protections don't apply to the r2.dev path. Audit bucket public-access settings the same way you'd audit an S3 bucket policy.
The R2 presigned URLs documentation is the canonical reference for signing mechanics and expiry limits; pair it with the public buckets guide, the event notifications guide, and the R2 pricing page for current numbers.
getSignedUrl(s3, new PutObjectCommand(...), { expiresIn: 604800 }) (7 days) and email it to a user. Two days later you realize you want to revoke just that one link. What can you actually do?It's not R2 charges — a presigned PUT still counts as one ordinary Class A operation, same as a binding-based put(). The savings is in Worker CPU time and request duration: the file bytes flow straight from the browser to R2's S3 endpoint, so your Worker never has to buffer or stream the payload through an isolate, which matters once files get into the tens/hundreds of MB.