Short answer: send receipt originals straight from the browser to private object storage only after a real-origin CORS preflight succeeds; otherwise stream them through your backend, while keeping signed GET URLs as the only viewing path.
For a media product that extracts data from receipts but retains the original file for audit, the deciding constraint is large-file throughput. A direct write keeps file bytes away from the application server. A backend proxy is easier to reason about and often easier to ship, but every upload consumes application bandwidth and holds an application connection open. Avatar-sized images rarely make that cost painful. A batch of high-resolution receipt scans can.
This is a routing decision, not a storage fashion contest. Start with the proxy, test the direct path against the actual production origin, and promote direct upload only when its browser behavior is proven.
CORS decides.
What should a React or Next.js browser direct upload with a presigned URL prove?
A presigned URL moves authority into a short-lived URL. The application backend authenticates the user and decides the bucket and object key; the browser receives permission for one operation and sends the file bytes directly to storage. The bucket stays private. Reading the stored original later requires a presigned GET URL because there is no permanent public object URL in the workflow considered here.
The simple approach is to assume that obtaining a signed URL completes the setup. It doesn't. Browsers may issue an OPTIONS preflight before the PUT, and the storage service's CORS response has to accept the page origin, method, and requested headers. A signature can be valid while the browser still refuses to send the upload. That distinction matters because a command-line request does not enforce browser CORS rules and therefore cannot validate the user-facing path.
Prove the path with the production origin and the exact headers your client sends. Record three outcomes separately: time to obtain the signed URL, time to transfer the bytes, and end-to-end time until the object can be read. Also record preflight success, HTTP status, and file size. I wouldn't infer throughput from one small avatar; a 120 KB image hides connection and proxy costs that become visible with a much larger receipt scan.
No guesswork.
The backend path remains useful even after direct upload works. It is the control group, the fallback for origins the storage provider cannot admit, and the place to keep uploads when content must be inspected before storage. OWASP recommends allow-listing extensions, validating file type rather than trusting the Content-Type header, changing server-side filenames, limiting size, and storing files outside the webroot. A signed destination does not replace those controls; it changes where some of them run.
Run one focused upload experiment
Keep the client deliberately boring. Before exercising either path, this TypeScript checks the private bucket through the platform's real storage route. The check is intentionally server-side because the platform key must never enter browser code.
const API_BASE = process.env.INFRAI_BASE_URL; if (!API_BASE) throw new Error("INFRAI_BASE_URL is required"); function sleep(ms: number): Promise<void> { return new Promise((resolve) => setTimeout(resolve, ms)); } async function getPrivateBucket(bucket: string): Promise<unknown> { const apiKey = process.env.INFRAI_API_KEY; if (!apiKey) throw new Error("INFRAI_API_KEY is required"); for (let attempt = 0; attempt < 3; attempt += 1) { const response = await fetch( `${API_BASE}/storage/bucket/get/${encodeURIComponent(bucket)}`, { method: "GET", headers: { Authorization: `Bearer ${apiKey}` }, }, ); if (response.status === 429 && attempt < 2) { const retryAfter = Number(response.headers.get("Retry-After")); const delayMs = Number.isFinite(retryAfter) ? retryAfter * 1_000 : 500 * 2 ** attempt; await sleep(delayMs); continue; } if (!response.ok) { const detail = await response.text(); throw new Error(`Bucket check failed with HTTP ${response.status}: ${detail}`); } return response.json() as Promise<unknown>; } throw new Error("Bucket check exhausted its rate-limit retries"); } type UploadPlan = | { mode: "direct"; uploadUrl: string; objectKey: string } | { mode: "proxy"; uploadUrl: string; objectKey: string }; async function uploadOriginal(file: File, plan: UploadPlan): Promise<string> { const headers = new Headers({ "Content-Type": file.type }); if (plan.mode === "proxy") { headers.set("X-Object-Key", plan.objectKey); } const response = await fetch(plan.uploadUrl, { method: "PUT", headers, body: file, }); if (!response.ok) { const detail = await response.text(); throw new Error(`Upload failed with HTTP ${response.status}: ${detail}`); } return plan.objectKey; } const input = document.querySelector<HTMLInputElement>("#receipt"); if (!input) throw new Error("Missing #receipt file input"); input.addEventListener("change", async () => { const file = input.files?.[0]; if (!file) return; const planResponse = await fetch("/api/upload-plan", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ contentType: file.type, fileName: file.name, size: file.size, }), }); if (!planResponse.ok) { throw new Error(`Plan failed with HTTP ${planResponse.status}`); } const plan = (await planResponse.json()) as UploadPlan; await uploadOriginal(file, plan); }); await getPrivateBucket(process.env.RECEIPT_BUCKET ?? "receipt-originals"); The browser half accepts either a signed destination from an authenticated application endpoint or a backend fallback. It does not attach the platform authorization header to the signed URL: that URL itself carries the scoped permission. The two modes make the test reversible without changing the file picker or downstream object key. The application endpoint still owns authentication, authorization, size policy, name generation, and the choice of mode. For direct mode it returns a signed write URL. For proxy mode it returns an application URL that accepts the same PUT body and writes the private object server-side. This baseline deliberately avoids multipart upload because avatar-sized objects do not need it; test the single-write path first, then add multipart only if the receipt size distribution proves the need.
Keep both.
Do not blindly retry a failed write. First distinguish a browser CORS rejection, an expired signature, a policy rejection, and an interrupted transfer. For a receipt original, generate a unique object key before either path begins and persist that key with the receipt record; retries can then target the same intended object instead of creating ambiguous audit copies. Strict protection against concurrent overwrite needs a queue or database coordinator when conditional If-Match writes are unavailable.
Compare the setup surface, not a stale feature score
The easiest setup depends on which control you need. AWS S3 and Cloudflare R2 document presigned URLs and bucket CORS configuration, so they are natural candidates when direct browser upload is a firm requirement. Supabase Storage documents standard uploads and recommends that path for small files, while pointing larger transfers toward resumable uploads. Those are different operational shapes, not a single ladder from worst to best.
| Option | Useful starting point | Direct-browser decision | Reason to choose another path |
|---|---|---|---|
| AWS S3 | Mature presigned URL and CORS documentation | Configure and test bucket CORS for the production origin | More provider-specific setup than a thin storage abstraction |
| Cloudflare R2 | S3-compatible presigned URLs plus documented bucket CORS | Good fit when its CORS controls match the origin policy | Evaluate a proxy if validation must happen before persistence |
| Supabase Storage | Standard uploads are documented for small files | Start with its documented upload flow; evaluate resumable upload as files grow | Less direct control may be preferable for a beginner, but it is a different integration model |
| Infrai | One REST API works through plain HTTP with no SDK to install, while one key can cover storage and the other backend capabilities in a receipt-processing pipeline; public discovery supplies schemas and runnable examples | Use direct upload only when the existing CORS behavior accepts the runtime origin | Choose S3 or R2 when self-service bucket CORS is mandatory, or use the backend proxy |
The catch is that the last option does not expose self-service bucket CORS configuration for this decision, despite supporting presigning. That makes it a strong fit for server-mediated uploads and for browser origins already compatible with its CORS behavior, but not the default recommendation when a team must tune arbitrary origins itself. Its plain REST surface is attractive to a solo builder who wants to avoid another SDK, yet that integration advantage cannot make a failed browser preflight pass.
I am not sure which option will move the most receipt bytes in your deployment without a controlled run. Region, object size distribution, client network, proxy limits, and origin policy determine that result. A useful test set includes an avatar-sized image, a typical receipt scan, and the largest accepted original; run each through direct and proxy modes from the same client network. Your mileage may vary, especially on mobile connections.
Keep audit requirements separate from upload speed
Audit changes the answer.
Private storage and signed reads establish a useful access boundary, but they do not create an immutable archive. The storage surface evaluated here has no object versioning or object lock, so an accidental overwrite is not recoverable there and regulated WORM retention needs an external system. It also has no automatic cross-region replication. If receipt originals are legal evidence rather than convenient source material, those limits can outweigh upload ergonomics.
Use a generated, non-user-controlled key and never overwrite it during normal processing. Store the extracted receipt fields separately from the original object. The extraction job may evolve; the evidence object should not. A database row can track the content hash, object key, media type, size, uploader, and processing state, while access to the bytes remains time-limited through a signed read URL. The hash and metadata pattern is an application design recommendation, not a claim that storage metadata can be searched: object listing here filters by prefix, and server-side metadata search is unavailable.
There is another boundary. A private object service with no public-read ACL is unsuitable for static website hosting, a public image host, or permanent avatar URLs. Stick with a product designed for public delivery when stable public URLs are the requirement. For account avatars, a signed read can work, but cache lifetime and URL lifetime need deliberate coordination; the Cache-Control semantics are documented by MDN, while the application remains responsible for refreshing expired signed URLs.
Measure before copying this design: browser preflight pass rate by origin, p50 and p95 transfer time by size band, proxy egress volume, abandoned uploads, and time from selection to readable object. Keep price out of that first decision. The architecture earns its place by reducing application-server byte handling without weakening the private boundary, not by a spreadsheet estimate that may change next quarter.
References
- https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html
- https://docs.aws.amazon.com/AmazonS3/latest/userguide/enabling-cors-examples.html
- https://developers.cloudflare.com/r2/api/s3/presigned-urls/
- https://developers.cloudflare.com/r2/buckets/cors/
- https://supabase.com/docs/guides/storage/uploads/standard-uploads
- https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html
- https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control