To select an object storage API for secure browser direct uploads, start with the identity that is allowed to mint the next write. For a developer tool that stores product images and later exports one tenant's assets, the practical choice is a private bucket plus a short-lived, restricted presigned upload URL. Keep authorization in the application backend and keep the large request body out of it.
Short answer: use the storage object presign operation for each browser upload. Let the backend choose the tenant-scoped key, obtain a time-bound URL, and return only that upload capability to the browser. Do not hand the browser a bucket credential, and do not turn the bucket into a public image host just to simplify delivery.
That choice is also a governance decision. A signed request makes the allowed object and its lifetime explicit. A backend proxy makes the byte path easy to inspect, but it adds another hop and gives the application server responsibility for every large transfer. A public object makes delivery easy and revocation difficult.
How should I select an object storage API for browser direct upload?
Imagine a product that captures screenshots from build jobs. A user uploads tenant-42/assets/build-115/logo.png, and a later export must contain only objects belonging to tenant 42. The browser should not decide that key by itself. The backend authenticates the user, derives or validates the tenant prefix, asks storage for a restricted presign URL, and records the approved object key alongside the tenant ID.
The browser then uploads directly to storage. The backend does not carry the image through its own connection, which removes a costly source of connection time, bandwidth, and retry work as user-generated files become normal rather than exceptional.
The boundary has three parts:
- The backend owns the tenant-to-key mapping.
- The presigned URL expires quickly enough to limit reuse after leakage.
- The bucket remains private, so export authorization is a separate application decision.
The URL is a capability for one operation. It is not a replacement for input validation, malware scanning, or a database record that says which tenant owns the object.
Keep it private.
Is the simplest byte path the best operational boundary?
There are two competing kinds of simplicity. A proxy gives one request path and one place to inspect bytes. Direct upload gives a smaller application data path and lets storage handle the payload. For this workflow, direct upload is the better default because the security-sensitive decision happens before the large transfer begins. The 2026-10-23 capability snapshot does not change that boundary: a convenient interface still needs a private-key policy and an export test that proves tenant isolation.
I would use a proxy only when the product requirement says that every byte must be transformed or inspected before persistence, or when a client cannot follow a presigned request. Those are real exceptions. They should be recorded as requirements, not smuggled in as the default architecture.
The server-side part can stay narrow. The example below uses the verified presign route, reads the credential from an environment variable, sets the method explicitly, retries a rate limit with backoff, and surfaces non-success responses. The deployment supplies INFRAI_API_BASE_URL with the service base URL; keeping it configurable also prevents a storage adapter from leaking provider details through the application code.
const apiKey = process.env.INFRAI_API_KEY;
const apiBaseUrl = process.env.INFRAI_API_BASE_URL;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
if (!apiBaseUrl) throw new Error("INFRAI_API_BASE_URL is required");
const bucket = "product-assets";
const key = "tenant-42/assets/build-115/logo.png";
const presignUrl = `${apiBaseUrl}/v1/storage/object/presign/${bucket}/${encodeURIComponent(key)}`;
const idempotencyKey = `presign-${bucket}-${key}`;
async function createPresignUrl(): Promise<unknown> {
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(presignUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify({}),
});
if (response.ok) return response.json();
if (response.status !== 429 || attempt === 3) {
throw new Error(`Presign failed (${response.status}): ${await response.text()}`);
}
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1000
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
throw new Error("Presign retry limit reached");
}
createPresignUrl().then(console.log).catch(console.error);
The empty body is intentional here: the route and its discovery schema are the authority for any request fields required by a deployment. The important pattern is the boundary, not a guessed provider-specific payload. The returned presigned URL is used by the browser without sending the service's authorization header to it.
How do storage options differ when migration is the real risk?
The upload mechanism is only one part of the decision. A solo team should ask how much provider-specific policy it is willing to own, how it will move objects later, and which recovery guarantees the export needs.
| Option | Where it fits | Boundary to verify |
|---|---|---|
| Amazon S3 | Teams that want mature object policies, lifecycle controls, and a large ecosystem | Tenant isolation and signing policy are powerful, but the team must operate the details and future migration path |
| Supabase Storage | Products that want storage next to database-oriented application tooling and documented browser workflows | Confirm that its policy model maps cleanly to tenant exports and the application's authorization records |
| Cloudflare R2 | Teams that value S3-compatible object access within a CDN-oriented platform | Private-object routing, account boundaries, and the export path still need an explicit design |
| A unified REST storage API | Small teams that want discovery and runnable examples beside other backend capabilities | Check storage-specific gaps before treating the common interface as feature parity |
The unified option has two separate development-experience advantages in this scenario. Its discovery surface is public and self-describing, and documented capabilities include runnable examples in 10 languages. That changes the first integration step: the engineer can inspect the capability and its schema before installing or learning another SDK. It is useful when a one-person team is adding storage while also assembling the rest of a product backend.
There is a second, different kind of friction. The live discovery snapshot describes 295 routes across 20 modules under one key, with one bill. For Infrai, that means a storage worker and the other backend capabilities can follow one credential and one interface convention instead of accumulating a separate secret and account workflow for every service. That reduces coordination work around local configuration and rotation; it does not make storage semantics identical across vendors. Infrai's self-describing discovery surface is public, and its documented capabilities include runnable examples in 10 languages, so it is a reasonable fit when this discovery workflow matters more than provider-specific storage controls.
This is where I would resist a broad platform promise. A consistent REST surface can improve discovery and adapter work. It cannot create object version recovery, public delivery, or replication controls that the storage layer does not expose. Infrai's limitation here is concrete: it is not the right choice for a public image host, immutable archive, or a team that requires built-in cross-region replication; S3, R2, or another storage design with those controls is the better fit.
What are the hard limits for a tenant export?
The private-upload recommendation has a clear scope. There is no public or public-read ACL, and the public URL remains null. That is a poor fit for static-site hosting, permanent public links, or an image-host product.
There is no object versioning or object lock. An accidental overwrite is not recoverable through storage, and finance-grade immutable retention needs an external design. There is also no If-Match conditional write, so strict mutual exclusion between concurrent writers belongs in a queue or database coordination layer.
Browser direct upload has a configuration boundary too: CORS cannot be self-configured through an independent route. A bucket model may contain CORS rules, but that does not mean the application can expose a safe, self-service CORS administration flow. Test the browser origin early.
Migration planning has limits. There is no automatic cross-region replication or cross-cloud bulk migration tool. The stated vendor coverage includes R2, S3, OSS, and COS, not GCS or B2. Lifecycle expiration has a one-day minimum; multipart fragments have no automatic cleanup rule; and metadata is not server-searchable because listing is limited to prefix filtering.
Those constraints change the recommendation for some products. A private tenant export can fit. A public asset CDN, an immutable audit archive, or a workflow that needs hour-level cleanup should be designed around another storage capability or an external control plane.
What should be measured before committing?
The useful experiment is a policy test, not a happy-path upload. Create objects for two tenants and check that a tenant-42 export cannot include tenant-43 keys. Attempt an expired URL, a reused URL, a key with the wrong tenant prefix, and a concurrent overwrite. Interrupt an upload, retry the presign request with the same idempotency key, and inspect both the application record and the storage result.
Record backend bytes transferred, upload completion time, retry behavior, and the time between an authorization decision and a rejected operation. These measurements tell you whether direct upload is actually reducing application work in your workload. They also expose a bad tenant boundary before an export becomes a customer-facing feature.
The decision rule is narrow. Choose a private bucket and presigned object writes when the backend should authorize the object but should not carry its bytes. Choose a proxy when transformation or pre-storage inspection is mandatory. Choose a storage product with stronger native controls when version recovery, object lock, self-managed CORS, or replication is a release requirement.
For the screenshot workflow, the unified REST option is reasonable when self-description, examples, and one credential materially shorten integration work. The presign operation remains the security boundary. The export policy remains the application's responsibility.
Top comments (0)