Guide

How to accept file uploads from a static site with Cloudflare R2

12 November 2026 · About a 7-minute read

“Attach your CV”, “send us a photo of the damage”, “upload the signed form”. On a server-rendered site that is a few lines of framework code. On a static site there is no server to receive the bytes, so you need a small endpoint that accepts the upload, checks it, and puts it somewhere private. On Cloudflare, that endpoint is a Worker and the somewhere is an R2 bucket.

The shape of it

  1. The HTML form uses method="post" and enctype="multipart/form-data", with an <input type="file">.
  2. The form posts to a Worker route. The Worker reads it with await request.formData(); the file field comes back as a File.
  3. The Worker validates size and type, generates its own object key, and calls env.BUCKET.put(key, bytes, { httpMetadata }) on an R2 binding.
  4. It stores the key (not the file) next to the rest of the submission in a database, and returns a thank-you page.

The bucket stays private. Nobody gets a public URL to what they or anyone else uploaded. You read files back through an authenticated admin route that streams the object from R2.

Five mistakes worth avoiding

1. Trusting the browser’s MIME type. file.type is whatever the client claims. Read the first bytes and check the signature: a PDF starts with %PDF, a PNG with 89 50 4E 47, a JPEG with FF D8 FF, a WebP with RIFF…WEBP. Accept only types you have actually planned for, and reject a file whose bytes and declared type disagree.

2. Using the uploaded filename as the key. Names like ../../admin.html or 300-character Unicode soup are how path and display bugs happen. Make the key yourself, for example formId/uuid/sanitized-name.pdf, and keep the original name only as a cleaned-up label.

3. Serving uploads inline. If an admin page opens an uploaded file in the browser, an uploaded HTML or SVG file can run script on your domain. Store a fixed content type from your allowlist and set Content-Disposition: attachment so files download instead of rendering.

4. No size cap of your own. Workers do enforce a maximum request body size per plan (100 MB on Free at the time of writing), but that is far bigger than a contact form needs. Check file.size before reading the bytes and pick a small limit, such as 5 MB, that matches the job.

5. Leaving the endpoint open. An upload endpoint without spam protection is free storage for strangers. Use the same stack as a contact form: Turnstile verified server-side, a honeypot field, an origin allowlist, and a per-IP rate limit. Run those checks before you write anything to R2.

What it costs

For a typical small site, nothing. R2’s free tier includes 10 GB-month of storage plus a monthly allowance of write and read operations, and R2 does not charge for egress. A few hundred 2 MB attachments a month is a rounding error inside that. Check Cloudflare’s R2 pricing page for current numbers before you rely on them.

When to use presigned URLs instead

If people upload large videos or design files, routing every byte through a Worker is the wrong tool. Instead, have the Worker hand out a short-lived presigned URL for R2’s S3-compatible API and let the browser upload directly. That adds CORS configuration on the bucket and a second step to record the upload, so for “attach a PDF to this form” the simple Worker route above is easier to get right.

Deleting what you no longer need

Uploads tend to be the most personal thing a form collects. Decide how long you keep them, delete the R2 object when you delete the submission, and consider an R2 lifecycle rule to expire old objects automatically. This is housekeeping advice, not legal advice.

A kit, if you do not want to write it

Formharbor ships an upload form that does exactly this: signature-based type checks, a size cap per form, generated keys, attachment-only downloads from a password-protected dashboard, and R2 deletes when you delete a submission. It runs on the same Worker as the contact form and the referral waitlist. Everything above is enough to build it yourself; the kit is for when you would rather have it working today.

More guides

Want this already built?

Formharbor is a $19 one-time kit: contact forms, referral waitlists, and file uploads on your Cloudflare free tier.

Get Formharbor