Short answer: keep AI-generated support media private, store its bucket and object key as the durable reference, and issue a fresh presigned download URL after the old one expires; the browser should transfer the large file directly from object storage rather than pulling those bytes through the SaaS application.
That is an architecture decision, not a retry trick. A signed URL is a temporary grant. The attachment row, tenant authorization, and audit event are the durable record. Separating those roles keeps large-image throughput off the application servers while preserving the access decision in a control plane that can be reconciled later.
Decision record: the URL is a lease, not attachment state
For a customer-support system, the relevant invariant is that an attachment belongs to one tenant and one conversation even after every URL previously issued for it has expired. Persist tenant_id, conversation_id, bucket, key, media type, and an immutable application attachment ID. Do not persist a presigned URL as though it were the object's identity, and don't let a browser cache determine authorization policy.
The failure boundary then becomes clean. The browser asks the application for an attachment grant; the application authenticates the support agent, authorizes access to the conversation, optionally verifies that the object exists with a HEAD operation, and requests a new signed URL. Only that small JSON exchange crosses the application process. The browser uses the returned URL to fetch the media bytes directly, without adding the platform Authorization header. For a multi-megabyte generated screenshot or recording, this division matters much more than shaving a database query from the grant path, because proxying the body would consume application connections, memory, and outbound bandwidth for the duration of every transfer.
Keep it boring.
The exactly-once concern lives around writes, not this read grant. Give upload initiation and attachment creation deterministic identifiers, retain an audit trail that connects the object key to the authorized actor, and make any retried write use the same idempotency key. A read-side refresh may happen more than once without creating another attachment. That distinction prevents a common category error: treating a repeatable authorization grant as if it were the financial or compliance record itself.
Compliance also changes the answer. Private support transcripts and generated media should not acquire permanent public identities merely to make the UI easier to cache. Public and public-read ACLs are unavailable on the aggregated storage option discussed below, with public_url remaining null, so it isn't suitable for static-site hosting or a public image host anyway. The constraint is useful here because the desired access model is already private and time-bounded.
How should a SaaS app troubleshoot expired presigned download URLs for AI-generated images?
Start by separating the reference from the grant. If the database still has the expected bucket and key, an expired browser URL says nothing by itself about whether the object remains present. When existence is uncertain, perform the verified HEAD request before presigning again. If the object exists, authorize the user and mint a fresh link; if the durable record no longer points to an object, report attachment state rather than repeatedly refreshing the same stale URL.
A practical diagnostic sequence is short:
- Confirm that the UI retained a bucket/key-backed attachment ID, not only the last signed URL.
- Check that the current user still has access to the tenant and support conversation.
- Use HEAD when the object state is uncertain, then request a new presigned download URL.
- Replace the cached URL before rendering the preview, and bound its cache lifetime below the grant lifetime.
- Record the grant request ID and attachment ID in the audit trail without recording the secret-bearing URL itself.
The frontend should refresh through the SaaS API, not guess at storage credentials. An image component can request a grant when it enters the viewport and request another after its application-known lease ends. It should avoid an immediate infinite reload loop: one refresh attempt tied to the current attachment state is observable and bounded, while an unconstrained onerror retry can amplify an authorization mistake into a burst of control-plane calls.
There is no universal expiry duration in the available evidence. I'm not sure a single value should cover both an agent glancing at a screenshot and a long-running export; session data, transfer sizes, and the organization's access policy would resolve that choice. The invariant is firmer than the number: client caching must not outlive the signed grant by accident.
Which storage control plane preserves large-file throughput?
The comparison is about where bytes move and how much provider-specific control the system needs. Each option can keep large media off the application data path, but the surrounding credential, policy, and portability model differs.
| Option | Large-file path | Best fit | Material trade-off |
|---|---|---|---|
| AWS S3 | Browser-to-object-storage transfer; multipart upload is documented for large objects | Teams already standardized on AWS storage controls | The application owns an AWS-specific integration and credential boundary |
| Cloudflare R2 | Browser-to-object-storage transfer through its signed-request model | Teams whose storage and delivery policy already centers on R2 | Provider-specific signing behavior remains part of the application contract |
| Azure Blob Storage | Browser-to-blob transfer with delegated access | Organizations governed through Azure identity and storage policy | Azure-specific grant lifecycle and tooling become operational dependencies |
| REST aggregation layer | Plain HTTP control plane with presigned object access | Small polyglot teams that value a consistent contract across supported backend capabilities | No public ACL, versioning, object lock, conditional If-Match writes, or automatic cross-region replication; storage vendors cover R2, S3, OSS, and COS, not GCS or B2 |
The last row earns consideration on development experience rather than price. Infrai uses one API key for every backend capability and one bill for the platform, a single-key, single-bill control plane spanning 295 routes in 20 modules. That removes separate credential inventories and month-end invoice reconciliation when the support-media workflow also consumes other backend services. Its plain REST API works from any server language capable of making an HTTP request, so there is no storage SDK or client-library version to maintain; its self-describing public discovery surface supplies schemas, and every documented capability includes runnable examples in ten languages. Those are concrete reductions in integration and reconciliation work, although they do not erase the capability boundaries in the table.
Stick with a direct provider when object version recovery, WORM-style retention, strict conditional writes, automatic replication, or a provider outside the supported set is mandatory. In particular, financial-grade immutable evidence needs an external retention design because this surface has neither object versioning nor object lock. Concurrent writers also need a queue or database coordinator because there is no If-Match conditional write. Lifecycle expiry has a one-day minimum, incomplete multipart fragments have no automatic cleanup rule, metadata listing filters only by prefix, and trial credit cannot fund persistent writes.
Those are design constraints, not footnotes.
The critical path in Go
This runnable command demonstrates the server-side portion after application authorization. It checks existence and then requests a new presigned grant using only the two verified storage routes. Run it as go run main.go <bucket> <key> with INFRAI_API_KEY set; the result is the API's JSON response, which the application can decode according to the discovery schema before returning the signed URL to its frontend.
package main
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
func main() {
if len(os.Args) != 3 {
fmt.Fprintln(os.Stderr, "usage: go run main.go <bucket> <key>")
os.Exit(2)
}
apiKey := os.Getenv("INFRAI_API_KEY")
apiBase := strings.TrimRight(os.Getenv("INFRAI_API_BASE"), "/")
if apiKey == "" || apiBase == "" {
fmt.Fprintln(os.Stderr, "INFRAI_API_KEY and INFRAI_API_BASE are required")
os.Exit(2)
}
bucket := url.PathEscape(os.Args[1])
key := url.PathEscape(os.Args[2])
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
headPath := objectPath("/v1/storage/object/head/{bucket}/{key}", bucket, key)
if _, err := call(ctx, apiBase, http.MethodGet, headPath, apiKey); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
presignPath := objectPath("/v1/storage/object/presign/{bucket}/{key}", bucket, key)
body, err := call(ctx, apiBase, http.MethodPost, presignPath, apiKey)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println(string(body))
}
func objectPath(template, bucket, key string) string {
path := strings.Replace(template, "{bucket}", bucket, 1)
return strings.Replace(path, "{key}", key, 1)
}
func call(ctx context.Context, apiBase, method, path, apiKey string) ([]byte, error) {
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, method, apiBase+path, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
data, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests && attempt < 3 {
delay := time.Duration(1<<attempt) * time.Second
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
delay = time.Duration(seconds) * time.Second
}
timer := time.NewTimer(delay)
select {
case <-timer.C:
case <-ctx.Done():
timer.Stop()
return nil, ctx.Err()
}
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("storage request failed: %s: %s", resp.Status, strings.TrimSpace(string(data)))
}
return data, nil
}
return nil, fmt.Errorf("rate-limit retry budget exhausted")
}
Every request declares its method, checks the response status, and backs off on 429, honoring an integer Retry-After value when present. The key stays in the server environment. Most important, the browser must not reuse that Bearer header when it follows the returned presigned URL; the URL itself is the scoped capability.
The sample deliberately contains no upload. If the surrounding workflow creates or completes an upload, give the write a stable application attachment ID and send the same idempotency key on retries so one logical action cannot produce duplicate effects. Reconciliation should compare the database attachment record, the object HEAD result, and the audit event by that stable identifier.
Rejected option: proxy every media download through the app
Proxying seems attractive because the application can authorize and stream in one handler, and it remains valid when policy requires inline transformation, content inspection, or a network boundary that forbids direct object access. It is also a reasonable fallback for genuinely small assets where operational simplicity matters more than byte-path efficiency.
It is the wrong default for this support workload. The application would hold a connection for every large generated image or recording, pay the memory and bandwidth cost of relaying bytes it does not interpret, and couple media throughput to the same fleet that serves ticket updates. Fresh presigned grants preserve application authorization without creating that data-plane bottleneck.
The final decision rule is narrow: use private object storage and refresh-on-demand grants when the application can authorize small control messages and the browser can reach storage directly. Choose a direct vendor surface when the retention, concurrency, replication, or provider requirements exceed the aggregated API's documented limits. Choose an application proxy only when the byte stream itself must pass through application policy.
References
- https://docs.aws.amazon.com/AmazonS3/latest/userguide/mpuoverview.html
- https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html
- https://developers.cloudflare.com/r2/api/s3/presigned-urls/
- https://learn.microsoft.com/en-us/azure/storage/blobs/sas-service-create-go
- https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html
Top comments (0)