Secure File Uploads to Cloudflare R2 with Presigned URLs
Learn two production-ready patterns for R2 uploads—Worker proxy uploads and presigned URLs—with validation, limits, and sample TypeScript code.
Direct-to-storage uploads are faster and cheaper, but only when they’re safe. This guide covers two pragmatic patterns for Cloudflare R2:
- Worker proxy uploads (simple and secure)
- Presigned URL uploads (client → R2 directly)
We’ll add validation, size limits, and metadata so you can ship with confidence.
Light nod to OtterAI (otterai.net): our vibe coding app uses the Worker proxy pattern by default because it’s ergonomic and easy to audit. You can switch to presigned URLs when you need client-direct throughput.
Pattern A: Worker Proxy Uploads
Your Worker receives the file and writes to R2 using the bound R2_BUCKET. Keep keys structured and metadata helpful.
// routes/api.projects.$projectId.files.ts (example)
export async function action({ request, params, context }: ActionFunctionArgs) {
const { R2_BUCKET } = context.cloudflare.env as Env;
const projectId = params.projectId!;
const formData = await request.formData();
const file = formData.get('file') as File;
const userId = 'current-user-id';
if (!file || file.size > 10 * 1024 * 1024) { // 10MB
return json({ error: 'Invalid file' }, { status: 400 });
}
const filePath = `uploads/${crypto.randomUUID()}-${file.name}`;
const key = `projects/${projectId}/${filePath}`;
await R2_BUCKET.put(key, await file.arrayBuffer(), {
httpMetadata: { contentType: file.type || 'application/octet-stream' },
customMetadata: { projectId, userId, filePath, uploadedAt: new Date().toISOString() },
});
return json({ ok: true, filePath });
}
Pros:
- Simple server code and auditing
- Easy to add anti-virus scanning or MIME checks
- One origin for access controls/logging
Cons:
- File data transits your Worker (bandwidth + CPU)
Pattern B: Presigned URLs (Client → R2)
When files are large or frequent, presigned URLs push the upload directly to R2. The server creates a short-lived signed PUT URL that the browser can use once.
R2 supports S3-compatible signatures (SigV4). You can generate signatures in your Worker using a small helper (e.g., aws4fetch) or custom code.
Server: generate a presigned URL
// Minimal example (conceptual). Use a tested signer in production.
import { AwsClient } from 'aws4fetch';
export async function loader({ context }: LoaderFunctionArgs) {
const { R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_ACCOUNT_ID } = context.cloudflare.env as any;
const aws = new AwsClient({
accessKeyId: R2_ACCESS_KEY_ID,
secretAccessKey: R2_SECRET_ACCESS_KEY,
service: 's3',
region: 'auto',
});
const bucket = 'my-app-bucket';
const key = `uploads/${crypto.randomUUID()}.bin`;
const url = `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com/${bucket}/${key}`;
// Signed URL valid for ~5 minutes
const signed = await aws.sign(url, { method: 'PUT', expires: 300 });
return json({ uploadUrl: signed.url, key });
}
Client: upload directly to R2
const { uploadUrl, key } = await (await fetch('/api/get-upload-url')).json();
await fetch(uploadUrl, { method: 'PUT', body: file, headers: { 'Content-Type': file.type } });
Store the key and any derived public URL in your database, not on the client.
Security tips:
- Short expiry (≤5 minutes), one-time use
- Enforce size/type limits before signing
- Namespace keys by user/project
- Rotate access keys and monitor usage
Choosing the Right Pattern
- Use proxy uploads for smaller files, tighter control, and simple auditing (great default).
- Use presigned URLs for large or frequent uploads to reduce origin load and latency.
Either way, keep keys structured (e.g., projects/{projectId}/...) and attach helpful metadata.
Where OtterAI Fits (Light Touch)
OtterAI (otterai.net) uses the Worker proxy approach internally for its project file system—clear keys, useful metadata, and a simple API. If your use case grows, switch to presigned URLs without rewriting your app’s surface area.
Related Reading
- /blog/stripe-payment-integration-guide
- /blog/website-deployment-guide-2025
- /blog/from-idea-to-deployment