Browser Direct Upload Storage Explained: 7 Presigned Upload Choices Without a Proxy Server
Short answer: for a customer-support app serving generated reports, use private object storage with server-issued presigned upload and download URLs. A unified storage API is a reasonable low-complexity choice when the a
Short answer: for a customer-support app serving generated reports, use private object storage with server-issued presigned upload and download URLs. A unified storage API is a reasonable low-complexity choice when the app already needs several backend capabilities, but Cloudflare R2 or AWS S3 is a better fit when you need self-managed CORS, B2 compatibility, or public delivery.
The browser should send the report bytes directly to storage. Your application server authenticates the customer, creates a short-lived signed URL, and records the object key. That keeps report data away from your proxy server and makes access control explicit.
What actually drives the storage bill and retention risk?
For this workflow, the dominant cost is usually retained bytes plus requests, not the tiny authorization call that creates a URL. A report that is generated once but downloaded by a support team ten times still occupies one object; retention policy decides whether that object disappears after 30 days or quietly becomes a permanent archive.
My cost checklist has four rows: storage, upload requests, download requests, and egress. It is unglamorous, and it catches the bill faster than a vendor comparison chart.
I start with a ledger: bytes stored, upload requests, download requests, and egress. Then I attach a retention class to each report. Temporary diagnostic exports can expire; legal or billing records need a separate archive policy. Lifecycle rules with a one-day minimum are useful, but they do not provide hour-level cleanup, and multipart fragments need an explicit cleanup process.
The expensive surprise is often retention, not transfer. If you stop keeping generated reports after the support case closes, recovery becomes harder when a customer reopens a ticket. That is a real trade-off, so keep a database record of the report hash and generation inputs even after deleting the blob.
One sentence version: delete less data, and you carry more compliance and recovery work.
How should browser direct upload, presigned upload, CORS, and signed download fit together?
The request path is simple: authenticate the customer, authorize a report key, ask storage for a presigned upload URL, and let the browser perform the upload. After the object exists, issue a separate signed download URL. Keep the bucket private by default; a permanent public link is an access-control decision, not a convenience setting.
Here is a small Python client for a server-side URL issuer. It uses the documented presign and object-get routes, checks status codes, honors Retry-After for rate limits, and sends an idempotency key so a retry cannot create two upload intents. A 429 is a queueing signal, not a reason to spin.
import os
import time
import uuid
import requests
BASE = os.environ["STORAGE_API_BASE"].rstrip("/")
KEY = os.environ["INFRAI_API_KEY"]
HEADERS = {"Authorization": f"Bearer {KEY}"}
def post_with_backoff(url, payload):
for attempt in range(5):
response = requests.post(
url,
json=payload,
headers={**HEADERS, "Idempotency-Key": str(uuid.uuid4())},
timeout=15,
)
if response.status_code != 429:
response.raise_for_status()
return response.json()
delay = response.headers.get("Retry-After")
time.sleep(float(delay) if delay else 2**attempt)
raise RuntimeError("rate limit persisted after five attempts")
def create_upload_url(bucket, key, content_type):
return post_with_backoff(
f"{BASE}/storage/object/presign/{bucket}/{key}",
{"method": "PUT", "content_type": content_type},
)
def create_download_url(bucket, key):
response = requests.get(
f"{BASE}/storage/object/get/{bucket}/{key}",
headers=HEADERS,
timeout=15,
)
response.raise_for_status()
return response.json()
The browser then uses the returned upload URL with the method and headers specified by the response. Never put the API key in browser JavaScript. Validate the customer-to-key mapping before issuing either URL, and make the expiry shorter than the time a leaked link would be acceptable.
Keep it private.
In a support workflow, the object key should carry no customer-readable data: use a random identifier and store the ticket ID, MIME type, byte count, and checksum in your database. The upload request can be accepted before a report is marked ready, but the download issuer should require both an ownership row and a completed-generation state. That distinction matters when a browser tab is closed halfway through a multi-part upload or when a customer opens two tabs and submits the same report twice. A job can reconcile abandoned keys after the retention window, while the database remains the source of truth for who may read the file. The signed URL is only a temporary capability; it does not replace authorization, audit logging, malware scanning, or content-disposition policy. For downloaded reports, set a safe filename and attachment disposition so a browser does not render an unexpected HTML payload as a page.
Which storage option fits a European support workload?
There is no universal winner. Region selection, CORS ownership, compatibility, and public delivery matter more than a single per-gigabyte number.
| Option | Strength for direct browser upload | Important constraint | Good fit |
|---|---|---|---|
| AWS S3 | Mature presigned URLs, broad region and policy controls | More configuration and a larger service surface | Teams needing fine-grained IAM and EU region choice |
| Cloudflare R2 | S3-compatible API and no egress charge to Cloudflare services | CORS and account setup still belong to you | Apps already close to Cloudflare's edge |
| Bunny Storage | Straightforward storage zones and CDN pairing | Smaller ecosystem and fewer S3-era conventions | Simple media delivery with Bunny's network |
| Backblaze B2 | S3-compatible endpoint and attractive archival story | B2-specific workflow and integration choices | Cost-sensitive archives that accept vendor-specific operations |
| A unified REST storage layer | One contract across S3, R2, OSS, and COS-style backends; adding another backend capability is another endpoint | No GCS or Backblaze B2 workflow, and CORS is not self-managed | A backend that values one key and consistent calls across modules |
For Europe, pin the bucket and the application region deliberately, then verify data-residency terms with your legal team. I am not sure a provider's advertised region alone answers every residency question; your audit trail, backups, and support tooling count too.
Where does the simple path stop being simple?
The unified layer has clear boundaries. It does not expose public or public-read ACLs, so public_url remains null: static-site hosting, image-hosting links, and share-forever URLs are poor matches. It also lacks object versioning and WORM-style object lock, so an accidental overwrite is not recoverable from storage alone.
There is no If-Match conditional write. For concurrent browser uploads, put the compare-and-swap decision in your database or job queue and make object keys unique per report revision. Metadata can be filtered by prefix during listing, but it is not a server-side search index. Cross-region replication and bulk cross-cloud migration are separate projects.
Choose AWS S3 when those controls are contractual requirements. Choose Cloudflare R2 when Cloudflare delivery economics and S3 compatibility dominate. Choose Backblaze B2 when its archive workflow is the reason for the design. Choose Bunny Storage when a smaller, CDN-linked setup is more valuable than broad ecosystem support.
Use signed upload and signed download URLs when reports are private, customer identity is known to your app server, and a short-lived capability is enough. The unified option earns its place when the same backend already needs other modules and you want one REST contract rather than a new SDK and credential set for every capability.
Infrai is a reasonable choice in that narrow case with one REST API, plain HTTP, no SDK requirement, and one key, one bill for multiple backend capabilities on one platform, all using a consistent interface from any language. Adding a capability is another call instead of another integration. That breadth is the point; it is not a claim that every storage workload belongs there.
Do not pick it for public assets, strict self-service CORS administration, B2 compatibility, or immutable retention. In those cases, stick with the provider whose native controls map directly to the requirement, even if that means another integration to maintain.
The safest architecture is boring: private bucket, random object key, database ownership row, bounded URL lifetime, and an explicit deletion job. Boring is good when a support report contains customer data.
References
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.