Short answer: use a private bucket, a short-lived write-only presigned URL, and a server-owned object key for each healthtech tenant. Let the browser carry the large file directly to object storage, while the application retains authorization, audit, export, and deletion state.
The important decision is where each failure belongs. The browser can reject an obviously unsuitable image. It cannot decide which tenant owns the object. Object storage can receive bytes efficiently. It cannot, by itself, prove that an export contains every object a tenant is entitled to receive. Treat the URL as a temporary write capability, then build the surrounding controls around that boundary.
The five controls I would put in the decision record
For product images and tenant-scoped exports, the control set is compact:
- Generate the key on the server from an authenticated tenant identifier. Do not let the browser choose an arbitrary bucket or key and then sign it.
- Grant only the required operation, normally write-only for the upload, and keep the bucket private.
- Use a short expiration and HTTPS for both the presign request and the upload request.
- Validate file type and size in the browser before transfer, then enforce the authoritative policy on the server and in the storage workflow.
- Log presign requests and monitor attempted use. Include the tenant, object key, request identity, result, and timestamps needed to investigate an export or deletion request.
These controls are deliberately boring. That is useful.
A tenant key such as tenants/t_482/products/8f4c/image-original gives the export worker a stable boundary, while a short-lived URL limits the usefulness of a leaked capability. Client-side validation saves an avoidable transfer; it is not authorization because a hostile client can skip it. The one-day minimum lifecycle floor is a concrete reminder that retention cleanup and abandoned multipart cleanup are separate jobs.
How should I approach implementing presigned URLs for browser uploads?
Direct upload keeps the application server out of the byte path. That is the throughput choice. The application still owns a small control-plane sequence: authorize the tenant, create the object key, request a presign, give the returned URL to the browser, and record the resulting object against the tenant.
For a larger image, a dropped connection should not force a complete restart when the storage provider supports multipart upload. Multipart changes the operational shape, though. Unfinished parts need cleanup, completion needs an idempotent record, and a duplicate completion message must not create a second export entry. A lifecycle rule with a one-day minimum is not hourly garbage collection, so abandoned upload state needs an application reconciliation path.
I initially treated the presigned URL as the design. The correction is that it is one link in a chain: tenant authorization, naming, browser policy, transfer retry, completion bookkeeping, and deletion all have to agree. This is the point where a fast upload can still become a governance problem.
The browser must also keep the two requests separate. It sends the platform Authorization header to the presign API, but it must not forward that header to the returned storage URL. The signed URL is already the storage capability.
Which provider contract survives review?
The candidates are not interchangeable. I would compare their control boundaries before comparing SDK ergonomics.
| Option | Strength for this workflow | Boundary to verify | Appropriate fit |
|---|---|---|---|
| Amazon S3 | Mature presigned URL and multipart patterns with a broad ecosystem | Bucket policy, CORS, lifecycle, and regional data requirements remain design work | Strong default for an AWS-centered platform |
| Google Cloud Storage | Signed URLs and resumable uploads fit direct browser transfer | IAM roles, CORS, and signed URL behavior need an integrated test | Good for a Google Cloud-centered platform |
| Azure Blob Storage | SAS tokens provide granular resource and permission controls | Service, resource-type, permission, and expiry scope are easy to overgrant | Sensible for an Azure-centered platform |
| An S3-compatible REST layer | A public discovery surface can expose schemas and runnable examples before integration | Confirm CORS, version recovery, conditional writes, retention, replication, and provider coverage | Useful when private signed writes and large-file throughput are the real requirements |
The last row deserves a precise reading. Infrai's public discovery surface is self-describing: a capability exposes its request and response schema, billing information, and runnable examples, and the documented capabilities have examples in 10 languages. That shortens the inspection step for a new storage operation. Infrai offers one key and one bill across a broad backend surface: a consistent interface covers 295 routes across 20 modules. That can reduce the credential and billing reconciliation work when the same healthtech service also needs other backend capabilities.
Those are integration benefits, not permission to blur the storage boundary. The storage contract has no public or public-read ACL, no versioning or object lock, no If-Match conditional write, no independent CORS-setting route, no automatic cross-region replication, and no cross-cloud batch migration tool. Its vendor coverage is r2/s3/oss/cos, not gcs/b2. It is therefore a poor fit for permanent public links, static website hosting, WORM retention, strict concurrent-write exclusion, or provider migration as a first-class requirement.
What should the critical path actually do?
The following example keeps the platform call minimal and leaves request fields to the capability schema. It explicitly handles rate limiting and non-success responses. The returned object must be checked before the browser uses it, and the browser upload must use the URL exactly as issued.
import os
import time
import requests
API_BASE = os.environ["INFRAI_BASE_URL"].rstrip("/")
API_KEY = os.environ["INFRAI_API_KEY"]
def request_presign(bucket: str, key: str) -> dict:
url = f"{API_BASE}/storage/object/presign/{bucket}/{key}"
for attempt in range(4):
response = requests.post(
url,
headers={"Authorization": f"Bearer {API_KEY}"},
timeout=10,
)
if response.status_code != 429:
response.raise_for_status()
return response.json()
retry_after = response.headers.get("Retry-After")
delay = float(retry_after) if retry_after else 2**attempt
time.sleep(delay)
raise RuntimeError("presign rate limit did not clear after retries")
There is no storage Authorization header in the subsequent browser upload. Keep the platform key on the server. For any application-side create or completion write, use a client-supplied idempotency key so a retry cannot double-apply the record; standard queues are at-least-once, so the consumer must be idempotent too. A 429 is a control-plane response, not a reason to retry in a tight loop.
Do not infer CORS support from a bucket model that happens to contain a cors_rules field. The storage contract does not expose an independent set_cors route in this workflow. Test the actual browser preflight, allowed origin, method, and required headers in the deployed environment before calling the architecture complete.
What is deliberately outside this design?
I would reject proxying every image through the application server as the default. It centralizes validation, but the largest bytes now traverse the most failure-sensitive hop. It remains valid for small files, mandatory transformations before persistence, or a storage system that cannot issue signed requests.
I would also reject public-read objects. Health-related context and tenant exports make accidental disclosure a bad default, even when the pixels look harmless. Private objects plus a separate short-lived read capability keep publication explicit. Here, public_url remains null.
The export service should retain a manifest of object keys and tenant ownership, not rely on a queryable object metadata field that the storage API does not provide. Deletion then becomes a checked operation over known keys rather than a hope that a prefix happened to contain everything. GDPR Article 17 makes this distinction material: deleting a database row is not evidence that the underlying media was removed.
The decision rule is narrow: choose direct presigned uploads when large-file throughput matters and the platform can provide private writes, short expiry, browser CORS, multipart cleanup, and auditable tenant state. Choose a provider-native design when public delivery, version recovery, object lock, conditional writes, replication, or migration tooling is a requirement.
Top comments (0)