This site is not affiliated with or endorsed by Cloudflare, Inc. It showcases deployable experiments across most Cloudflare products.
Storage & Data

Presigned R2 Upload

Generate presigned PUT URLs for direct browser-to-R2 uploads with aws4fetch - secure client uploads without Worker proxying

Generate presigned PUT URLs so browsers upload files directly to R2 without routing file bytes through the Worker. Includes a demo HTML form at GET /.

Features

  • POST /presign - Returns a time-limited upload URL
  • Direct browser upload - Client PUTs to R2 using the signed URL
  • Content-type validation - Allowed types enforced at presign time
  • Demo page - GET / with file picker and upload flow

API Reference

POST /presign

Generate a presigned PUT URL for R2.

Prop

Type

Example Request

curl -X POST "https://your-worker.workers.dev/presign" \
  -H "Content-Type: application/json" \
  -d '{"filename":"photo.png","contentType":"image/png"}'

Success Response

{
  "uploadUrl": "https://account.r2.cloudflarestorage.com/bucket/uploads/uuid-photo.png?X-Amz-...",
  "key": "uploads/550e8400-e29b-41d4-a716-446655440000-photo.png",
  "contentType": "image/png",
  "maxBytes": 5242880,
  "expiresInSeconds": 900
}

Upload the file with a matching Content-Type:

curl -X PUT "$uploadUrl" -H "Content-Type: image/png" --data-binary @photo.png

Error Codes

  • 400 - Invalid JSON (INVALID_BODY), filename (INVALID_FILENAME), or content type (INVALID_CONTENT_TYPE)
  • 502 - Presign failed, often missing R2 credentials (PRESIGN_ERROR)

GET /

Returns an HTML demo page with presign + direct PUT upload flow.

Use Cases

  • Offload large file uploads from Workers to R2
  • Learn aws4fetch presigned URL generation at the edge
  • Prototype user-generated content uploads with size/type constraints

Limitations

  • Requires R2 API credentials as Worker secrets (R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY)
  • Browser uploads need R2 CORS configured for your origin
  • Signed Content-Type must match the client PUT header exactly
  • Documented max size 5 MB (enforced at presign documentation level)

Use in your project

Copy these files into an existing Worker. Prefer Deployment to try the full experiment first. Source: apps/experiments/presigned-r2-upload.

DependenciesNoneBindingsR2PlatformFetch API
export const DEMO_HTML = `<!DOCTYPE html><html lang="en"><head>  <meta charset="utf-8" />  <title>Presigned R2 Upload</title>  <style>    body { font-family: system-ui, sans-serif; max-width: 640px; margin: 2rem auto; padding: 0 1rem; }    label { display: block; margin: 1rem 0 0.25rem; }    input, select, button { width: 100%; padding: 0.5rem; }    pre { background: #f1f5f9; padding: 1rem; overflow: auto; }  </style></head><body>  <h1>Direct R2 Upload</h1>  <p>Get a presigned URL, then PUT the file directly to R2 from your browser.</p>  <label>Filename <input id="filename" value="demo.png" /></label>  <label>Content-Type    <select id="contentType">      <option value="image/png">image/png</option>      <option value="image/jpeg">image/jpeg</option>      <option value="text/plain">text/plain</option>      <option value="application/pdf">application/pdf</option>    </select>  </label>  <label>File <input id="file" type="file" /></label>  <button id="upload">Upload via presigned URL</button>  <pre id="log"></pre>  <script>    const log = document.getElementById('log');    document.getElementById('upload').onclick = async () => {      const filename = document.getElementById('filename').value;      const contentType = document.getElementById('contentType').value;      const file = document.getElementById('file').files[0];      if (!file) { log.textContent = 'Choose a file first'; return; }      const presign = await fetch('/presign', {        method: 'POST',        headers: { 'Content-Type': 'application/json' },        body: JSON.stringify({ filename, contentType })      }).then(r => r.json());      if (presign.error) { log.textContent = JSON.stringify(presign, null, 2); return; }      const put = await fetch(presign.uploadUrl, { method: 'PUT', headers: { 'Content-Type': contentType }, body: file });      log.textContent = JSON.stringify({ presign, putStatus: put.status }, null, 2);    };  </script></body></html>`;

Deployment

Click the deploy button

Deploy to Cloudflare Workers

Configure R2 and secrets

wrangler secret put R2_ACCESS_KEY_ID
wrangler secret put R2_SECRET_ACCESS_KEY

Set R2_ACCOUNT_ID in wrangler.json vars. Configure R2 CORS for browser PUT uploads.

Test presign

curl -X POST "https://your-worker.workers.dev/presign" \
  -H "Content-Type: application/json" \
  -d '{"filename":"test.txt","contentType":"text/plain"}'

Local Development

cd apps/experiments/presigned-r2-upload
npm install
npm run dev

Open http://localhost:8787/ for the demo upload form.

Configuration

SettingPurpose
UPLOADSR2 bucket binding
R2_ACCOUNT_IDAccount ID for signing (var)
R2_ACCESS_KEY_IDR2 API token access key (secret)
R2_SECRET_ACCESS_KEYR2 API token secret (secret)

Cloudflare Features Used

  • Workers - Edge compute runtime
  • R2 - Object storage with presigned uploads

On this page