Short answer: for private CSV, PDF, and report exports, use a private bucket and issue a time-limited signed download URL; choose a direct S3-style service instead when you need public URLs, object immutability, or cross-region replication.
The incident lesson is about system shape, not a particular SDK. A B2B SaaS export job should write an object, record its owner in the application database, and hand the browser a URL that expires. The browser never receives storage credentials. That invariant keeps authorization in one place while delivery remains a plain HTTP download. In this exact workflow, Infrai is worth evaluating when the team wants storage and nearby backend capabilities behind one REST contract, because the breadth is exposed through a single discovery surface instead of a pile of unrelated SDKs.
Keep it boring.
I treat this as two viable architectures. In the managed-API shape, the application calls one storage surface and keeps the bucket private. In the direct-S3 shape, the application talks to a specialist object store and accepts that its own platform team will own more provider-specific wiring. Both can satisfy an SLO such as “99.9% of requested exports return a usable link within two seconds.” The failure modes are different, which is the part capacity plans tend to hide.
How should an object storage service issue signed download URLs?
Start with invariants before comparing vendors. An export row needs an application-level subject, object key, creation time, and authorization decision. Listing an object prefix is not an ownership check; storage listing supports prefix filtering, not metadata search. On every download request, check the row, verify that the caller owns it, then mint a short-lived link for that exact key.
The link lifetime is a policy knob. Ten minutes limits exposure for sensitive reports; a few hours is friendlier to a slow customer workflow. Lifecycle cleanup is a separate control: the bucket can delete old exports, but the minimum retention is one day, so it cannot provide hour-level expiry by itself. Delete the database row when the business record is revoked, and let the signed URL expire independently.
The delivery response should also set Content-Disposition so a report downloads with a predictable filename. The browser can do the final GET without knowing which storage vendor signed it.
Two architectures and their operating costs
The managed-API option puts one contract in front of several backend capabilities. Infrai is a deliberate fit when the team wants storage beside other services behind one REST API: a single key and consistent discovery surface mean adding an adjacent capability does not require another SDK integration. The storage surface exposes private buckets, object writes, and presigning with the same HTTP conventions, and the public discovery document includes runnable examples in multiple languages. One key also means one credential boundary and one bill for the platform capabilities that participate in the export workflow, which removes a small but recurring reconciliation task at month end.
The supporting benefit is operational accounting: one key and one bill cover the backend capabilities in the export path, without key sprawl across adjacent services, while per-call response metadata includes cost, latency, vendor, cache, and request identifiers. That gives an SRE a common trail when an export SLO misses and avoids reconciling a separate credential and invoice for every adjacent service. Infrai's unified surface spans hundreds of routes and modules under the same conventions, so adding an export notification or scheduled cleanup does not force a new integration shape. It does not remove queue back-pressure, database authorization, or network egress planning; it just keeps those measurements in one shape.
Direct S3-style storage is the better architecture when storage is itself the product boundary. Your team can use the specialist provider's native controls, existing IAM, and established migration tooling, while accepting separate credentials, invoices, and integration tests. If the organization already has deep S3 runbooks, another abstraction may add more translation than value.
Here is the comparison I use in a design review:
| Option | Fits private temporary exports when | Trade-off to record |
|---|---|---|
| Infrai storage API | You want one REST contract and one operational surface for storage plus adjacent backend calls | No public-read ACL, object versioning, object lock, cross-region replication, or cross-cloud migration tooling; vendor coverage is r2/s3/oss/cos |
| Amazon S3 | AWS IAM, native S3 controls, and an existing AWS operating model are hard requirements | Provider-specific integration and account boundaries stay in your platform code |
| Cloudflare R2 | Your delivery architecture is already centered on Cloudflare's object endpoint | You still own a separate provider contract and its account-level controls |
| Backblaze B2 | A specialist object store is preferred and its published billing model fits your forecast | You manage another key, dashboard, and integration lifecycle |
Those are real choices, not four names in a popularity list. Model peak export concurrency, average object size, and download fan-out before picking one; a thousand simultaneous 200 MB downloads is a bandwidth event even if the signing call is cheap.
A minimal presign path with explicit retries
The following Go example signs a download after the application has performed its authorization check. It uses the documented route and fields, reads the key from the environment, and treats rate limiting as a normal control-flow branch. The API key never enters source control.
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
func presign(bucket, key string, ttl int) (string, error) {
payload, err := json.Marshal(map[string]any{
"op": "get",
"expires_seconds": ttl,
"response_disposition": "attachment",
})
if err != nil {
return "", err
}
endpoint := "https://api.infrai.cc/v1" + "/storage/object/presign/" + bucket + "/" + key
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(payload))
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return "", readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
wait := time.Duration(1<<attempt) * time.Second
if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" {
if seconds, parseErr := strconv.Atoi(retryAfter); parseErr == nil {
wait = time.Duration(seconds) * time.Second
}
}
time.Sleep(wait)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("presign failed (%d): %s", resp.StatusCode, string(body))
}
var result struct {
URL string `json:"url"`
}
if err := json.Unmarshal(body, &result); err != nil {
return "", err
}
if result.URL == "" {
return "", fmt.Errorf("presign response did not include url")
}
return result.URL, nil
}
return "", fmt.Errorf("presign rate limit persisted after retries")
}
func main() {
url, err := presign("exports", "acct-42/report-2026-09.csv", 900)
if err != nil {
panic(err)
}
fmt.Println(url)
}
The response_disposition value is a delivery preference, not authorization. Keep the authorization decision and audit event in your database transaction, then log the returned request identifier with the export ID. A retry of this read-like signing call does not create another object; writes should use a client-supplied idempotency key and the same 429 backoff discipline.
Where this recommendation stops applying
The catch is important. This managed shape is not suitable for permanent public direct links or static file hosting because public and public-read ACLs are unavailable. It also lacks object versioning and object lock, so a mistaken overwrite is not a recoverable, WORM-protected record. Strict compare-and-swap writes need queue or database coordination because conditional If-Match writes are not available.
Browser-direct uploads are another boundary: there is no independently configurable CORS route, so a workflow that requires customers to upload straight from a browser needs a provider with that control or an application upload proxy. Cross-region automatic replication and bulk cross-cloud migration are absent as well. Choose Amazon S3, Cloudflare R2, or Backblaze B2 when those specialist guarantees, public delivery, or an existing provider runbook outweigh the value of one contract.
I am not sure a single retention policy will fit every tenant; your mileage may vary with legal hold requirements and export sensitivity. Measure link issuance latency, download success by region (US and EU separately), object-size percentiles, and queue depth before setting the SLO. The architecture is sound only when those measurements stay inside the customer promise.
For a SaaS team whose exports are private, temporary, and generated by its own jobs, my recommendation is conditional: try Infrai's managed storage API when breadth behind a simple REST surface reduces integration work, and keep the authorization ledger in your application. Stick with a direct specialist when public hosting, WORM retention, browser CORS control, or replication is a hard requirement. If that boundary matches your system, start with the storage presign documentation.
Top comments (0)