1---
2title: R2 presigned URLs
3description: Mint presigned R2 URLs so browsers upload and download objects directly, with the same code in alchemy dev and in production, from Effect-native or async Workers.
4---
5
6A presigned URL lets a browser upload or download one object in an
7[R2 bucket](/cloudflare/data/r2) directly, without credentials. Your
8Worker mints the URL, and the same code works deployed and under
9`alchemy dev`.
10
11## Effect-native Workers
12
13Bind the bucket with `PresignPutObject` / `PresignGetObject` and
14provide their `*Token` layers:
15
16```typescript
17// src/worker.ts
18import * as Cloudflare from "alchemy/Cloudflare";
19import * as Effect from "effect/Effect";
20import * as Layer from "effect/Layer";
21import { HttpServerRequest } from "effect/http/HttpServerRequest";
22import * as HttpServerResponse from "effect/http/HttpServerResponse";
23import { Uploads } from "./uploads.ts";
24
25export default Cloudflare.Worker(
26 "Api",
27 { main: import.meta.url },
28 Effect.gen(function* () {
29 const presignPut = yield* Cloudflare.R2.PresignPutObject(Uploads);
30 const presignGet = yield* Cloudflare.R2.PresignGetObject(Uploads);
31
32 return {
33 fetch: Effect.gen(function* () {
34 const request = yield* HttpServerRequest;
35 const url = new URL(request.url, "http://worker");
36 const key = url.searchParams.get("key")!;
37
38 if (url.pathname === "/upload-url") {
39 return yield* HttpServerResponse.json({
40 url: yield* presignPut({ key, contentType: "image/png" }),
41 });
42 }
43 return yield* HttpServerResponse.json({
44 url: yield* presignGet({ key, expiresIn: 60 }),
45 });
46 }).pipe(Effect.orDie),
47 };
48 }).pipe(
49 Effect.provide(
50 Layer.mergeAll(
51 Cloudflare.R2.PresignPutObjectToken,
52 Cloudflare.R2.PresignGetObjectToken,
53 ),
54 ),
55 ),
56);
57```
58
59Deployed, Alchemy mints a scoped API token for the Worker, and the
60URLs point at `https://{accountId}.r2.cloudflarestorage.com`. Under
61`alchemy dev` they point at the Worker's local R2 endpoint,
62`http://localhost:{port}/cdn-cgi/local/r2/s3`. Signing happens inside
63the Worker, so minting a URL makes no network request.
64
65The browser uploads with a plain `fetch`, sending the `Content-Type`
66that was signed into the URL:
67
68```typescript
69const { url } = await (await fetch(`/upload-url?key=${key}`)).json();
70await fetch(url, {
71 method: "PUT",
72 body: file,
73 headers: { "content-type": "image/png" },
74});
75```
76
77Both functions accept a few options:
78
79```typescript
80yield* presignPut({
81 key: "avatars/me.png",
82 expiresIn: 300, // seconds, 900 by default, 7 days at most
83 contentType: "image/png", // the uploader must send this header
84});
85
86yield* presignGet({
87 key: "reports/2026-09.pdf",
88 expiresIn: 60,
89 contentType: "application/pdf", // served with this Content-Type
90 contentDisposition: 'attachment; filename="report.pdf"', // force a download
91});
92```
93
94## Async Workers
95
96Async Workers sign URLs themselves with an S3 client, which needs S3
97credentials. `S3Credentials` is the easy way to get them: it creates an
98API token scoped to the permissions you ask for and hands the Worker
99its S3 keys. Declare it on `env`:
100
101```typescript
102// alchemy.run.ts
103import * as Cloudflare from "alchemy/Cloudflare";
104import { Uploads } from "./src/uploads.ts";
105
106export const Api = Cloudflare.Worker("Api", {
107 main: "./src/worker.ts",
108 env: {
109 UPLOADS_S3: Cloudflare.R2.S3Credentials(Uploads, { access: "write" }),
110 },
111});
112
113export type ApiEnv = Cloudflare.InferEnv<typeof Api>;
114```
115
116Then sign with any S3 client. This one uses
117[`aws4fetch`](https://github.com/mhart/aws4fetch):
118
119```typescript
120// src/worker.ts
121import { AwsClient } from "aws4fetch";
122import type * as Cloudflare from "alchemy/Cloudflare";
123import type { ApiEnv } from "../alchemy.run.ts";
124
125export default {
126 async fetch(request: Request, env: ApiEnv) {
127 const key = new URL(request.url).searchParams.get("key")!;
128 const s3: Cloudflare.R2.S3CredentialsValue = JSON.parse(env.UPLOADS_S3);
129 const client = new AwsClient({ ...s3, service: "s3" });
130
131 const path = key.split("/").map(encodeURIComponent).join("/");
132 const url = new URL(
133 `${s3.endpoint}/${encodeURIComponent(s3.bucketName)}/${path}`,
134 );
135 url.searchParams.set("X-Amz-Expires", "900");
136 const signed = await client.sign(url.toString(), {
137 method: "PUT",
138 headers: { "content-type": "image/png" },
139 aws: { signQuery: true, allHeaders: true },
140 });
141
142 return Response.json({ url: signed.url });
143 },
144};
145```
146
147`UPLOADS_S3` is a JSON string holding `endpoint`, `bucketName`,
148`region`, `accessKeyId` and `secretAccessKey`. Deployed, it's a
149secret. Under `alchemy dev`, no token is created and it points at the
150local endpoint with local credentials.
151
152`access` is required and sets the token's permissions:
153
154```typescript
155Cloudflare.R2.S3Credentials(Uploads, { access: "read" }); // downloads only
156Cloudflare.R2.S3Credentials(Uploads, { access: "write" }); // uploads only
157Cloudflare.R2.S3Credentials(Uploads, { access: "read-write" }); // both
158```
159
160In an Effect-native Worker, `yield* Cloudflare.R2.S3Credentials(Uploads, { access })`
161gives you the same values for your own S3 client.
162
163## Browser uploads to a deployed bucket
164
165A browser calling the URL from another origin needs a CORS rule on
166the bucket. The local endpoint allows any origin and ignores these
167rules, so a missing origin or header only shows up once deployed.
168
169```typescript
170// src/uploads.ts
171import * as Cloudflare from "alchemy/Cloudflare";
172
173export const Uploads = Cloudflare.R2.Bucket("Uploads", {
174 cors: [
175 {
176 allowedMethods: ["GET", "PUT"],
177 allowedOrigins: ["https://app.example.com"],
178 allowedHeaders: ["content-type"],
179 },
180 ],
181});
182```
183
184## Local development
185
186The local endpoint rejects expired and tampered URLs like R2 does,
187and objects uploaded through it are visible to the Worker's own R2
188bindings. S3 clients such as the AWS SDK work against it too. A
189bucket piped through `Alchemy.remote()` uses real R2 in dev.
190
191Mint presigned URLs on demand and store object keys, not URLs. URLs
192expire, and a local URL points at the Worker's dev port, which can
193change between `alchemy dev` runs.
194
195R2 doesn't support presigned POST (HTML form) uploads, locally or
196deployed. Use presigned `PUT` URLs.
197
198## Where next
199
200- [R2](/cloudflare/data/r2)
201- [Local development](/cloudflare/local-development)
202- [R2 API reference](/providers/cloudflare/r2)