Orange Book
Recipes

Recipe: production R2 delivery, CORS, and private uploads

Separate custom domains, CORS, and presigned URLs to deliver images, PDFs, and attachments safely in production.

Edited and verified by Orange Book Editorial Team ·

RECIPE · PRODUCTION DELIVERYAbout 28 minutesR2 · browser · custom domain

Conclusion first

Serve public reads through a custom domain. For direct browser uploads to a private bucket, issue a short-lived presigned URL on the server and allow only your exact application origin with CORS. Use r2.dev for non-production tests only. Presigned URLs use the R2 S3 API domain, not your custom domain.

Do not confuse these three controls

ControlWhat it solvesWhat it does not solve
Custom domainPublic reads, caching, and Cloudflare controls such as WAF, Access, and Bot productsPrivate-object authorization
CORSAllows selected browser origins to read a response or uploadAuthentication; non-browser clients are not protected by it
Presigned URLAuthorizes one S3 API operation for a limited timePermanent publication or secrecy from anyone who obtains the URL

Public assets

Site images, public downloads, and published CMS media use a custom domain with cache and security policy at that entry point.

Private upload

SaaS attachments, image inputs, and PDF files require application authentication before the server issues a short-lived PUT URL.

Private download

The server checks user, tenant, and object key before issuing a short-lived GET URL. Avoid personal data in object names.

Production workflow

Start with an access matrix

For each object class, name its readers, writers, public status, allowed Content-Type, maximum size, and retention. Avoid mixing permanently public assets with sensitive temporary files in one bucket unless prefixes and authorization boundaries are explicit.

Bind a custom domain for public reads

Bind a domain managed in the same Cloudflare account from the R2 bucket settings. After the custom domain is verified, disable r2.dev if the test entry point is no longer required. Otherwise readers may bypass protections that exist only on the custom domain.

Read-only verification
curl -I https://media.example.com/public/sample.webp

Check status, Content-Type, Cache-Control, ETag, and CF-Cache-Status; “it opens in my browser” is not enough.

Configure the smallest browser CORS policy

An origin must be an exact scheme://host[:port] without a path. List development and production origins separately. Do not use * merely to simplify private uploads.

Example R2 CORS policy
[
  {
    "AllowedOrigins": ["https://app.example.com"],
    "AllowedMethods": ["GET", "PUT"],
    "AllowedHeaders": ["Content-Type"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3600
  }
]

If an old CORS response is cached behind the custom domain, purge the affected cached objects after changing the policy, then repeat the preflight.

Sign URLs on the server only

Before signing, validate the session, tenant, object key, operation, Content-Type, and business quota. Keep the URL short-lived and return it over HTTPS. R2 accepts an expiry of up to seven days, but that is not a sensible default. Presigned URLs support GET, HEAD, PUT, and DELETE; S3 HTML form POST uploads are not supported.

Verify both allowed and rejected browser origins

Verify a CORS preflight
read -r -s -p 'Paste a short-lived presigned URL: ' R2_SIGNED_URL
curl -i -X OPTIONS "$R2_SIGNED_URL" \
  -H 'Origin: https://app.example.com' \
  -H 'Access-Control-Request-Method: PUT' \
  -H 'Access-Control-Request-Headers: content-type'
unset R2_SIGNED_URL

Use a newly generated, unexpired server-side URL. The allowed origin should receive matching CORS headers. Change Origin to an unapproved domain and confirm it does not receive the same allowance. Finally upload a small file from the real app, and verify that a wrong Content-Type, expired URL, and unauthorized key all fail.

A presigned URL is a bearer token

Anyone who obtains the URL can perform the signed operation until it expires. Do not put it in logs, analytics, or support tickets. Do not sign in the browser, and never ship account-level R2 credentials to a client.

Release and rollback checklist

  • Validate with a test bucket and test domain before production.
  • Keep the old read path until hit rate, errors, and download integrity are stable.
  • If uploads fail, stop issuing new URLs before deleting any old objects.
  • Restore the previous application entry point during rollback; reverse CORS, DNS, or cache changes one at a time and re-verify.

For existing data, continue to the R2 migration playbook. For cost modelling, read R2 vs S3, OSS, and B2.

Primary sources

Did this page help you complete your goal?

Beta feedback is generated in this browser and is never uploaded automatically.

On this page