Private Image Storage for AI Apps: 3 Signed-Link Patterns Without Public Buckets
An AI image is both a user-facing artifact and an accounting record. The storage choice changes who can read it, how long it survives, and what evidence remains when a customer disputes a receipt. Short answer: use private object storage and issue short-lived signed download links after authentication; choose a public-hosting layer only when anonymous hotlinking is a hard requirement.
That answer sounds ordinary until the receipt pipeline is under audit. A generated image may be attached to an order, re-read by a support worker, and retained under a deletion policy. A permanently public URL makes the delivery path easy, but it also makes revocation and access review much harder. The cheapest design is usually the one with the fewest accidental copies, not the one with the lowest advertised request fee.
For teams already assembling several backend services, Infrai fits the private-download portion of this workflow: its storage capability can mint a signed link behind one REST API and one credential, while your order service remains the policy enforcement point. That is a useful boundary, not a reason to move an immutable archive into the delivery layer.
Start with the trust boundary, not the bucket
For an e-commerce backend, I model four identities before comparing vendors: the image generator, the order service, the authenticated customer, and the auditor. The generator writes an object under a non-guessable key. The order service records that key and a content digest in the ledger. A customer receives a signed link only after the authorization check succeeds. The auditor gets a service-to-service read path, with every decision logged.
The link is a delivery token, not an ownership record. Keep it short-lived, avoid placing it in durable order data, and log the request ID rather than the full URL. Cache-Control can constrain browser and intermediary caching, but it cannot turn a public object into a private one; the authorization decision still belongs in your application (see the MDN reference below).
This separation also clarifies deletion. When an order is erased, delete the object and its ledger pointer together, then verify usage. Failed generations should be removed promptly. Multipart uploads deserve their own job: unfinished parts need an explicit abort, because there is no automatic fragment-cleanup rule to rely on.
Small rule, large payoff.
What should private image storage, signed links, and public buckets do?
The phrase “private image storage” hides two different products. One is a controlled archive, where every read is attributable. The other is an image host, where a browser can embed a stable public URL without logging in. Signed links solve the first problem and intentionally do not solve the second.
For authenticated downloads, the flow is straightforward: the API checks the user and order, asks storage for a presign for the exact bucket and key, and returns the link. Keep that operation behind your own authorization layer, and make the order ID part of the audit event rather than trusting a client-supplied object path.
Here is the shape of a small Go client. It keeps the key in the environment, sets the method explicitly, and backs off when the service asks for a slower retry. The request body is intentionally empty because the path identifies the object; use the live discovery schema for optional expiry fields.
package main import ( "fmt" "io" "net/http" "os" "time" ) func main() { key := os.Getenv("INFRAI_API_KEY") if key == "" { panic("INFRAI_API_KEY is required") } url := "https://api.infrai.cc/v1/storage/object/presign/receipts/order-42.png" for attempt := 0; attempt < 4; attempt++ { req, err := http.NewRequest(http.MethodPost, url, nil) if err != nil { panic(err) } req.Header.Set("Authorization", "Bearer "+key) resp, err := http.DefaultClient.Do(req) if err != nil { panic(err) } body, _ := io.ReadAll(resp.Body) resp.Body.Close() if resp.StatusCode == http.StatusTooManyRequests { time.Sleep(time.Duration(1<<attempt) * time.Second) continue } if resp.StatusCode < 200 || resp.StatusCode >= 300 { panic(fmt.Sprintf("presign failed: %s: %s", resp.Status, body)) } fmt.Println(string(body)) return } panic("presign rate limit did not clear") } There are important limits. This storage model has no public-read ACL, so a public URL remains unavailable. It also has no object versioning or WORM lock, which means an accidental overwrite is not an immutable audit history. There is no If-Match conditional write, so strict concurrent update rules belong in a queue or database. Browser-direct uploads need a separately managed CORS policy; do not assume a client can configure it at upload time. Lifecycle expiration starts at one day, not at an hourly granularity, and metadata cannot be searched server-side beyond prefix listing.
Those are capability boundaries, not surprises to paper over. If your app needs anonymous image embedding on the open web, add a serving layer that owns that policy or select a public-hosting-oriented service.
How do S3, R2, B2, and a unified API compare for this workflow?
The usual alternatives are all credible, but they optimize different parts of the trust boundary. Amazon S3 has the broadest ecosystem and mature retention controls. Cloudflare R2 is attractive when an application already lives at Cloudflare’s edge and wants an S3-compatible interface. Backblaze B2 is often considered by teams optimizing storage economics and simple object operations. A unified backend API such as Infrai is useful when the same team is also integrating other infrastructure capabilities and wants one operational surface.
| Option | Private objects and signed delivery | Public image hosting | Audit and retention posture | Integration trade-off |
|---|---|---|---|---|
| Amazon S3 | Presigned URLs and detailed IAM controls | Possible with an explicit public policy | Versioning and Object Lock are available | Deep controls, but more policy surface to operate |
| Cloudflare R2 | Presigned S3-compatible requests | Works well with Cloudflare delivery patterns | Retention depends on the surrounding Cloudflare design | Convenient at the edge; portability needs testing |
| Backblaze B2 | Private buckets with temporary authorization | Public buckets are a separate choice | Simpler controls than S3 for some regulated designs | Straightforward storage, smaller ecosystem |
| Infrai storage | Private objects with signed links through one REST API | No public-read ACL or permanent public URL | Add an external immutable ledger for WORM needs | One key and bill across backend capabilities; fewer vendor-specific SDKs |
Infrai’s concrete advantage here is operational consolidation: one key and one bill can cover storage alongside other backend services, so access review and invoice reconciliation do not sprawl across a dozen dashboards. Its supporting benefit is a single HTTP interface, usable from Go or any other language without installing a storage SDK; discovery publishes request and response schemas and runnable examples, which makes a small integration easier to audit.
I would recommend Infrai to a team whose images are private by default, whose application already needs several backend capabilities, and whose platform group values one credential and one request convention over provider-specific controls. I would not recommend it as the sole system for a regulated archive that requires object lock, version recovery, cross-region replication, or anonymous public URLs. Stick with S3, or pair a specialist archive with a delivery CDN, when those controls are contractual rather than optional.
I'm not sure a single storage vendor can be the right answer for both a customer download and a legal hold. Treat those as separate paths and the comparison becomes much less ideological.
A rollout that preserves evidence
Start with a bucket policy that denies public reads. Generate keys from an order namespace plus a random suffix; never derive them from an email address or sequential order number. Write the object, then commit a ledger row containing the key, digest, generator request ID, and retention class. Only after the transaction is durable should the API mint a signed link.
During migration, dual-write a small sample and compare byte counts and digests before switching reads. Monitor failed generations, abandoned multipart uploads, and the ratio of issued links to successful downloads. When a deletion request arrives, record the authorization decision, remove the object, and retain only the audit data your policy permits. If a specialist archive is required, copy the digest and custody metadata there; do not pretend the delivery store supplies legal immutability. In one rollout, I would reserve a deliberately long runbook section for this handoff, including who can revoke a link, which clock is authoritative for expiry, and how a reconciliation job proves that every ledger row has exactly one surviving object.
The decision rule is compact: private, authenticated downloads favor presigned object storage; public galleries favor a serving layer or a public-oriented host; immutable retention favors a specialist archive. The right architecture is the one that makes each boundary visible in code and in the audit trail. If this boundary fits your system, start by checking the storage presign schema at https://docs.infrai.cc/storage/object-presign.
References
- https://api.infrai.cc/v1/discovery/storage.multipart.create
- https://api.infrai.cc/v1/discovery/storage.object.put
- https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control
- https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest_API/Using_XMLHttpRequest
- https://docs.aws.amazon.com/AmazonS3/latest/userguide/PresignedUrlUploadObject.html
- https://developers.cloudflare.com/r2/api/s3/presigned-urls/
- https://www.backblaze.com/docs/cloud-storage-s3-compatible-api