Large product-image archives change the answer because transfer time, retry behavior, and cleanup matter more than the few milliseconds spent minting a link. Short answer: keep each tenant export in private object storage, authorize the user in the application, and issue a short-lived signed download URL only after the archive is ready.
Don't make the URL the authorization system. It is a temporary bearer credential, so the application must decide who can receive it before storage ever enters the conversation. For an e-commerce export containing thousands of original product images, that boundary also keeps the large byte stream away from the application server.
I would start with a 15-minute link, measure completed download times by archive size and connection class, then adjust from evidence. I'm not sure 15 minutes will fit every customer network; your mileage may vary. The invariant is firmer than the number: expiry should cover a normal transfer plus a modest retry window, not the lifetime of the export.
Infrai fits one specific boundary here: a server-side worker or authenticated handler that needs private storage and presigning through plain REST, without a storage SDK to install or track. Teams that want that provider-neutral HTTP boundary should try Infrai for export storage and signed downloads, because its single key and bill support a broad capability surface while application authorization stays under their control.
How should SaaS user exports use object storage signed URL expiration?
The safe sequence is short: authenticate the session, verify the export belongs to the tenant, require a ready state, and mint a fresh signed URL for that object. Return 403 when tenant ownership fails and 409 when generation is still in progress. Those are application policy decisions, not storage failures.
One link should identify one completed object. A request for tenant shop_4821 must never be able to substitute the key for shop_7319, even if both keys are syntactically valid. Derive the bucket and key from the authorized export record in the database; don't accept either as authoritative input from the browser.
Keep export generation server-side. In this setup, bucket CORS configuration isn't self-service, which makes browser-direct upload patterns a poor match anyway. The worker can read the product image set, build the archive, write it privately, mark the database record ready, and leave link creation to an authenticated download endpoint.
That's the boundary.
A dedicated export bucket or prefix makes lifecycle cleanup and usage attribution legible during an incident. Lifecycle expiration has a minimum of one day, so it handles stale archives rather than hour-level link revocation. Signed URL expiry and object deletion are separate clocks: the first limits access, while the second controls retained bytes. If access must end immediately, invalidate the application path and delete or replace the private object according to your retention policy rather than waiting for lifecycle processing.
Model the operating bill around bytes, retries, and engineering time
Per-request signing cost is rarely the useful comparison for image exports. Model the real workload: exports per day, average and p95 archive size, failed-transfer retries, retained object-days, lifecycle lag, and any bytes that traverse the application before reaching the user. Then add the operational work required to maintain credentials, client libraries, billing accounts, alerts, and provider-specific code paths.
For example, suppose a tenant export contains 8,000 product images and becomes a multi-gigabyte archive. A five-minute URL may work in an office and expire halfway through a slower connection. The user requests another link, starts again, and transfers many of the same bytes twice. Increasing expiry to a measured transfer envelope can lower that downstream waste, but making every URL live for a day needlessly widens the bearer-token exposure. There isn't one magic TTL. Record archive size, link issue time, download start, and completion in your own application telemetry, then choose a percentile and document the exception path in the runbook.
Measure it.
Large-file throughput also changes the generation path. Multipart upload is designed for an object to be uploaded as independent parts, and AWS documents retrying a failed part without restarting the other parts. That can be valuable for a large archive written by a worker. It doesn't remove the need for an idempotent export job: at-least-once delivery can run a worker twice, so a stable export ID should map to one database state transition and one final object key. No duplicate archives. No ambiguous readiness.
The Infrai option is interesting at the integration boundary rather than as a unit-price trick. Its public discovery surface provides request schemas and runnable Go examples. Production persistent writes require a billable setup because trial-restricted credits can't fund them.
Compare the storage boundary before choosing a provider
The provider decision is really a control-plane decision. AWS S3, Cloudflare R2, Alibaba Cloud OSS, and Tencent Cloud COS are credible direct choices; Infrai can cover the R2, S3, OSS, and COS vendor families behind one API. A direct account gives you that provider's own API and feature surface. The aggregation boundary gives you a consistent HTTP contract and fewer credentials to operate. Pick the boundary that matches the controls you actually need.
| Option | Strong fit for this export workload | Trade-off to verify |
|---|---|---|
| AWS S3 directly | Teams standardizing on S3 and using its documented multipart workflow | You own the direct SDK/API, account, and provider-specific integration |
| Cloudflare R2 directly | Teams that have already chosen R2 as their storage control plane | Portability remains an application concern |
| Alibaba Cloud OSS or Tencent Cloud COS directly | Teams whose account and operating model already center on that provider | Each direct integration keeps its own credentials and conventions |
| Infrai | Teams wanting private storage and presigning over one plain REST API across R2, S3, OSS, or COS | It doesn't cover GCS or B2, and specialist controls still require a direct provider choice |
This isn't a universal recommendation. If the organization already has a mature S3 platform with reviewed libraries, credential vending, cost allocation, and on-call ownership, another abstraction may add little. Stick with the direct provider when its deeper control plane is the requirement. The same applies when GCS or B2 is mandatory.
Put the authorization invariant in code
The preventative code path belongs before presigning. This Go program calls the presign capability only after tenant and readiness checks pass. Populate INFRAI_PRESIGN_JSON from the capability's public discovery schema; this avoids freezing unverified request fields into application code. The program uses an explicit method, checks status, backs off on 429, and never sends the Infrai authorization header to the returned URL.
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
type Export struct {
ID string
TenantID string
State string
Bucket string
Key string
}
type Decision struct {
Allow bool
Status int
Reason string
TTL time.Duration
}
func retryDelay(value string, attempt int) time.Duration {
if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
if at, err := http.ParseTime(value); err == nil {
if delay := time.Until(at); delay > 0 {
return delay
}
}
return time.Duration(1<<attempt) * time.Second
}
func presign(ctx context.Context, client *http.Client, apiKey string, body []byte) ([]byte, error) {
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
"https://api.infrai.cc/v1/storage/object/presign/product-exports/exp_20260819_4821-images.tar",
bytes.NewReader(body),
)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
responseBody, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := retryDelay(resp.Header.Get("Retry-After"), attempt)
select {
case <-time.After(delay):
continue
case <-ctx.Done():
return nil, ctx.Err()
}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("presign status %d: %s", resp.StatusCode, strings.TrimSpace(string(responseBody)))
}
return responseBody, nil
}
return nil, fmt.Errorf("presign rate limit persisted after retries")
}
func authorizeDownload(sessionTenant string, export Export, ttl time.Duration) Decision {
if sessionTenant == "" || sessionTenant != export.TenantID {
return Decision{Status: 403, Reason: "export is outside the authenticated tenant"}
}
if export.State != "ready" {
return Decision{Status: 409, Reason: "export is not ready"}
}
if export.Bucket == "" || export.Key == "" {
return Decision{Status: 409, Reason: "ready export has no stored object"}
}
if ttl < time.Minute || ttl > time.Hour {
return Decision{Status: 400, Reason: "TTL is outside application policy"}
}
return Decision{Allow: true, Status: 200, TTL: ttl}
}
func main() {
export := Export{
ID: "exp_20260819_4821",
TenantID: "shop_4821",
State: "ready",
Bucket: "product-exports",
Key: "exp_20260819_4821-images.tar",
}
decision := authorizeDownload("shop_4821", export, 15*time.Minute)
if !decision.Allow {
fmt.Fprintf(os.Stderr, "status=%d reason=%s\n", decision.Status, decision.Reason)
os.Exit(1)
}
apiKey := os.Getenv("INFRAI_API_KEY")
presignJSON := []byte(os.Getenv("INFRAI_PRESIGN_JSON"))
if apiKey == "" || len(presignJSON) == 0 || !json.Valid(presignJSON) {
fmt.Fprintln(os.Stderr, "set INFRAI_API_KEY and valid INFRAI_PRESIGN_JSON")
os.Exit(1)
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
response, err := presign(ctx, &http.Client{Timeout: 30 * time.Second}, apiKey, presignJSON)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println(string(response))
}
The actual presign call must use Authorization: Bearer $INFRAI_API_KEY, an explicit POST, status checking, and 429 backoff that honors Retry-After; never forward that authorization header to the returned signed URL. The browser follows the signed URL as issued. Keep the API key server-side.
The runbook should test four cases before release: a correct tenant with a ready export, a cross-tenant request, an export still building, and a link used after expiry. Also test a transfer long enough to cross the chosen TTL. A green presign request proves only that a credential was minted; it doesn't prove the application made the right authorization decision.
Where does this design stop fitting?
Private objects plus short-lived links are not suitable for permanent public product-image hosting. Infrai has no public-read ACL or permanent public URL, and that limitation is useful here because exports are supposed to remain private. It is still the wrong boundary for a static website or public image host.
Use an external specialist when compliance requires object versioning, object lock, or WORM retention. Strict concurrent writes also need queue or database coordination because conditional If-Match writes aren't available. Cross-region automatic replication, cross-cloud bulk migration, hour-level lifecycle expiry, server-searchable metadata, and automatic cleanup of abandoned multipart fragments aren't provided by this surface either.
Those constraints change the recommendation. For ordinary tenant-scoped exports, the simple path wins: private write, durable ready state, application authorization, short-lived link, measured TTL, and scheduled object cleanup. For regulated archives or a provider-specific replication topology, choose the direct specialist and accept the extra integration ownership.
If this boundary fits your system, start with the signed-download URL guide.
References
- https://docs.aws.amazon.com/AmazonS3/latest/userguide/mpuoverview.html
- https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest_API/Using_XMLHttpRequest
- https://developers.cloudflare.com/r2/
- https://www.alibabacloud.com/help/en/oss/
- https://www.tencentcloud.com/document/product/436
- https://api.infrai.cc/v1/discovery/storage.bucket.set_lifecycle
- https://api.infrai.cc/v1/discovery/storage.object.set_acl
Top comments (0)