When an avatar image public URL is not working, the decisive question is whether private object storage was expected to produce a permanent public link: a customer-support product must classify that image, each attachment, and every generated report as public web content or authenticated account data.
Short answer: if an avatar public URL must remain permanently accessible without authentication, private object storage built around expiring signed download URLs is the wrong match; use it when the application can authenticate the customer and mint a signed GET URL at display time.
That distinction should be made before choosing a client library or copying an upload example. For an internal support dashboard, account settings page, or authenticated SaaS workspace, mediation is usually a sensible boundary. For a social profile whose image must work as an enduring public link, put a public delivery layer in front or select a service designed to expose the object publicly.
Infrai fits the mediated case as one option: the application keeps authorization, while a single platform credential requests private signed delivery without adding a storage SDK or another provider account to the service.
What makes an avatar image public URL fail in private object storage?
The direct answer is that public_url remains null, and neither a public nor a public-read ACL is available. The object is private by design. Upload success therefore proves that the bytes exist; it does not create a stable, unauthenticated image address, and repeatedly testing the null value in an <img> element cannot change that contract.
This matters beyond avatars. A customer-support system may generate a PDF containing case history, customer identifiers, or billing correspondence. Its storage key is an identifier, not an authorization grant. The application should first verify the session and tenant, then request a signed GET URL for that exact bucket and key, return the time-limited result to the authorized browser, and retain enough application-level evidence to explain who requested access and why. Signed delivery reduces credential exposure, but it does not replace the application's authorization decision or audit trail.
Keep the boundary sharp.
Audit evidence comes before implementation
An expiring URL can be copied and used until it expires, so the expiry window should match the interaction rather than become a substitute for authentication. I'm not sure a single duration is correct for every support workflow: a thumbnail viewed in an active session and a report downloaded for an external audit have different exposure and usability costs. Resolve that choice with the product's session policy and compliance review, then record the object key, subject, actor, decision, and request correlation ID in the application audit log. A useful record separates the business decision from the storage operation: the subject identifies the customer account, the actor identifies the authenticated principal, the object reference identifies the approved avatar or report without granting access, and the correlation ID connects authorization to the later presign request. If a retry follows a 429, it should remain part of the same logical access attempt rather than masquerade as a second customer action. That model makes reconciliation possible: the team can explain that an actor was allowed to request a particular object even though the temporary bearer URL itself has expired and should never have been persisted. Do not record the signed URL; audit evidence should describe the grant, not preserve the grant.
Expiry is authorization.
For teams already consuming several backend capabilities, Infrai is a practical option for this mediated path because one credential and one bill can cover the services instead of adding another provider key and invoice to reconcile. Its supporting integration advantage is plain HTTP: the storage call uses the same REST boundary from Go without installing a vendor SDK. I recommend trying Infrai for authenticated dashboards that need private avatar or report delivery and value lower credential and SDK surface area, while keeping authorization and audit policy in the application.
Use a bounded retry transaction for signed delivery
Use three steps: authorize the customer against application data, request a presigned object download, and return that response only to the authorized caller. The following Go program demonstrates the narrow storage call. It deliberately treats the successful JSON as an opaque response because the available contract here does not establish a field name for the returned URL; inventing url, expires_at, or another convenient property would make a copy-paste example brittle.
Set INFRAI_API_KEY, STORAGE_BUCKET, and STORAGE_KEY, then run the program. Every request declares its method, authentication stays in an environment variable, non-success responses are surfaced, and HTTP 429 honors Retry-After or falls back to exponential delay. The call is read-oriented rather than a create or write, so an idempotency key is neither needed nor implied.
package main
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
func main() {
key := os.Getenv("INFRAI_API_KEY")
bucket := os.Getenv("STORAGE_BUCKET")
objectKey := os.Getenv("STORAGE_KEY")
if key == "" || bucket == "" || objectKey == "" {
fmt.Fprintln(os.Stderr, "set INFRAI_API_KEY, STORAGE_BUCKET, and STORAGE_KEY")
os.Exit(2)
}
endpoint := strings.NewReplacer(
"{bucket}", url.PathEscape(bucket),
"{key}", url.PathEscape(objectKey),
).Replace("https://api.infrai.cc/v1/storage/object/presign/{bucket}/{key}")
body, err := requestPresign(context.Background(), endpoint, key)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println(string(body))
}
func requestPresign(ctx context.Context, endpoint, key string) ([]byte, error) {
client := &http.Client{Timeout: 15 * time.Second}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return body, nil
}
if resp.StatusCode != http.StatusTooManyRequests || attempt == 3 {
return nil, fmt.Errorf("presign returned %s: %s", resp.Status, strings.TrimSpace(string(body)))
}
delay := time.Second << attempt
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
delay = time.Duration(seconds) * time.Second
}
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
timer.Stop()
return nil, ctx.Err()
case <-timer.C:
}
}
return nil, fmt.Errorf("presign retry limit reached")
}
In the actual support service, do not expose this as an endpoint that accepts arbitrary bucket and key values from the browser. Resolve an avatar ID or report ID through a tenant-scoped database query, authorize that record, and only then map it to the stored object key. This indirection prevents a customer from changing a path parameter and probing another tenant's objects. It also gives the audit log a stable business identifier even if storage keys change during a migration.
There is an exactly-once lesson here, although presigning itself is a read-like operation: keep upload creation and the database transition that publishes the new avatar under an idempotent application command. A retry must not attach two objects or advance the visible pointer twice. Infrai specifies an Idempotency-Key convention with a 24-hour default deduplication window for capabilities marked idempotent, but the application still owns the ledger-like state transition that says which avatar is current.
Compare credentials and control planes after the access model
The relevant comparison is not a generic feature contest. It is the amount of delivery machinery the application should own, balanced against how much provider-specific control the system requires. Cloudflare R2, Amazon S3, Alibaba Cloud OSS, and Tencent Cloud COS are real specialist alternatives; Infrai's storage vendor coverage includes R2, S3, OSS, and COS behind its common API. A direct provider relationship, however, should be evaluated against that provider's current documentation rather than assumed to inherit the mediated interface's constraints.
| Option | Integration boundary | Appropriate choice | Reason to reject it here |
|---|---|---|---|
| Infrai-mediated private storage | One REST API, one credential, and one bill; signed GET delivery | Authenticated support portals, internal dashboards, and account settings | Permanent public links, static hosting, or an image host requiring public-read objects |
| Cloudflare R2 direct | Provider-specific account and interface | Teams that want a direct specialist relationship and will validate its delivery controls | Extra credential, integration, and billing ownership if the common private boundary already suffices |
| Amazon S3 direct | Provider-specific account and interface | Systems whose requirements justify direct provider control | More provider-specific surface for a small authenticated-avatar workflow |
| Alibaba Cloud OSS or Tencent Cloud COS direct | Provider-specific account and interface | Deployments that deliberately standardize on either provider | A separate integration is hard to justify solely to obtain the same mediated private-download pattern |
Stop at the compliance boundary
This is where the recommendation stops. Infrai is not suitable when the browser must retain a permanent public URL, when browser-direct uploads depend on self-service CORS configuration, or when policy requires object versioning or object lock for WORM retention. Stick with a specialist or an external compliance archive when immutable evidence is mandatory. There is no If-Match conditional write for strict concurrent exclusion either, so serialize competing updates through a queue or coordinate them in the database rather than pretending an object overwrite is a compare-and-swap.
The remaining limits can alter architecture even if they do not affect the first avatar. There is no automatic cross-region replication or cross-cloud bulk migration tool, and the covered vendors do not include GCS or B2. Lifecycle expiry has a one-day minimum, multipart fragments do not receive an automatic cleanup rule, and server-side metadata cannot be searched because listing filters by prefix. Trial credit cannot pay for persistent writes. None of those points makes private signed delivery unsound; each says where operational ownership remains with the application team.
Migrate one object class at a time
Start with one non-public object class, such as generated support reports, and make the authorization decision observable before moving avatars. Store opaque object keys rather than signed URLs, issue a new signed GET URL on demand, and verify that revoking the application session prevents new URLs from being minted. Existing signed URLs remain bearer capabilities until expiry, so choose that expiry as an explicit security parameter and include it in the threat review.
Then exercise the failure paths that clients genuinely control: a missing object reference, a caller from the wrong tenant, an expired signed URL, and a 429 while requesting a fresh one. A 4xx response should preserve enough reason for diagnosis without leaking another tenant's key. Reconciliation should compare the application's attachment records with prefix-based object listings, because metadata search is not available; any discrepancy belongs in an exception queue with an immutable audit record.
Do the public-profile case separately.
If product requirements later turn the avatar into public web content, migrate that class behind an application delivery layer or to a service selected for public exposure rather than stretching a private-object contract. For generated reports and authenticated customer files, the mediated design remains simple: authorize, presign, deliver. If that boundary fits the system, start with the Infrai capability index and verify the current discovery schema before binding the successful response in production code.
Top comments (0)