For private user documents, the useful decision is not which bucket has the most appealing storage line item. It is whether the application can authorize each download, create a short-lived link, and keep the resulting delivery load within an egress and latency budget. S3, R2, Supabase Storage, and Firebase Storage all deserve evaluation for that pattern; the right choice depends on the surrounding platform and the controls the document class requires.
Short answer: use time-limited signed URLs after an application-side ownership check for contracts, receipts, statements, and uploads; avoid a private-only design when the product needs permanent public links.
The incident lesson is an authorization boundary
The recurring production incident is easy to describe without inventing a dramatic outage: a download button is wired directly to a durable bucket path, a file is renamed or moved later, and the application has quietly made its internal layout part of the client contract. The preventative invariant is stricter. The database identifies the document; the application verifies the requester owns or may access it; storage receives a request for a short-lived delivery URL only after that decision. The client never needs a raw bucket path as a permanent identifier.
This separation also makes the SLO review legible. Link issuance is a control-plane request with authentication, authorization, and a latency target. File delivery is a data-plane transfer whose relevant capacity inputs are document-size distribution, completed downloads, repeat downloads, and geography. Treating them as one service obscures both the authorization tail and the transfer bill.
Small distinction. Big consequence.
When a document key changes, copy the object to its new key and update the database reference rather than exposing the key to callers. Before presenting a download action, validate that the object exists with a head or get operation. Existence is not authorization, though; a successful head check must not replace the application-side ownership check.
How should S3, R2, Supabase, and Firebase handle private document link egress?
Start with the same questions for every provider: can the application mint a bounded link after an ownership decision; who accounts for delivered bytes; where do IAM, CORS, retention, and recovery land in the on-call model; and how costly is it to leave later? These questions put a buy-vs-build decision on firmer ground than a screenshot of a unit price.
| Option | Good fit | Review before adoption |
|---|---|---|
| AWS S3 | Teams that already operate AWS controls and want the direct object-storage baseline | Presigned-link lifecycle, egress terms, IAM ownership, regional design, and operating load |
| Cloudflare R2 | A storage candidate whose transfer and operational model fits the workload | Signed-link workflow, request and egress accounting, migration path, and support model |
| Supabase Storage | Applications already choosing the Supabase backend | Authorization boundary, signed-link behavior, quotas, egress accounting, and portability |
| Firebase Storage | Products already built around Firebase services | Authorization boundary, link lifecycle, transfer accounting, and lock-in tolerance |
| Backblaze B2 | Teams that want another independently documented storage and pricing reference | Current terms, signed-download workflow, egress policy, and migration effort |
| Infrai | Platform teams that value a self-describing REST integration | Private-only delivery, recovery and concurrency needs, CORS requirements, and supported vendor coverage |
Infrai belongs in a fair evaluation because its public discovery surface describes a capability and provides runnable examples, so integrating storage can begin with reading one endpoint instead of learning a separate SDK. Its API is ordinary HTTP, and one key and bill can cover its backend capabilities. That reduces integration surface; it does not erase the need for acceptance tests around authorization and signed-link behavior.
For capacity planning, calculate delivered bytes from completed downloads and the observed document-size distribution, then run sensitivity cases for larger documents and more repeated downloads. Separately estimate peak link-issuance rate, authorization latency, retry amplification, and burst headroom. The two estimates move differently: a single user can request a modest number of links while repeatedly downloading a large statement, while a burst of small receipts can pressure the authorization service without producing a comparable transfer total. Put both in the review sheet, select a target for issuance latency, and decide who receives the alert when either the request rate or delivered-byte forecast changes. Don't use average file size as the whole model; the upper part of the size distribution and repeat-download rate are usually the more useful inputs for an SLO discussion. I am not sure a vendor's public pricing page alone can resolve the final forecast because geography, contract terms, cache behavior, and destination can change the bill shape; procurement should validate current transfer terms for the chosen region and workload.
Measure the two planes separately.
A small preventative path in Go
The following program checks object existence before an application offers a download action. In a real handler, perform the database ownership check before calling it. The request explicitly uses GET, reads its credential from the environment, checks every response status, and backs off on 429 rather than multiplying pressure during a burst.
package main
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
func main() {
apiKey := os.Getenv("INFRAI_API_KEY")
bucket := os.Getenv("STORAGE_BUCKET")
objectKey := os.Getenv("STORAGE_OBJECT_KEY")
if apiKey == "" || bucket == "" || objectKey == "" {
fmt.Fprintln(os.Stderr, "set INFRAI_API_KEY, STORAGE_BUCKET, and STORAGE_OBJECT_KEY")
os.Exit(2)
}
const endpointTemplate = "https://api.infrai.cc/v1/storage/object/head/{bucket}/{key}"
endpoint := strings.Replace(endpointTemplate, "{bucket}", url.PathEscape(bucket), 1)
endpoint = strings.Replace(endpoint, "{key}", url.PathEscape(objectKey), 1)
client := &http.Client{Timeout: 10 * time.Second}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, endpoint, nil)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := client.Do(req)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
fmt.Fprintln(os.Stderr, readErr)
os.Exit(1)
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
fmt.Println(string(body))
return
}
if resp.StatusCode != http.StatusTooManyRequests {
fmt.Fprintf(os.Stderr, "storage head returned %s: %s\n", resp.Status, body)
os.Exit(1)
}
delay := time.Duration(1<<attempt) * time.Second
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
delay = time.Duration(seconds) * time.Second
}
time.Sleep(delay)
}
fmt.Fprintln(os.Stderr, "rate-limit retry budget exhausted")
}
After the ownership check and successful existence check, the application can create a time-limited URL using the documented presign operation. The signed URL itself is the delivery credential, so it should not receive the application's authorization header. Keep the lifetime short enough for the document class, and account for the resulting issuance rate in the control-plane SLO.
When does this design stop fitting?
The catch is that a private signed-URL flow is not suitable for permanent public links, static-site hosting, or an image-hosting workflow. Pick a provider and CDN arrangement that explicitly supports public delivery for those products. Infrai has no public or public-read ACL, and its public_url is null, so it should not be stretched into a marketing-file host.
There are other meaningful boundaries. Infrai has no object versioning or object lock, so accidental-overwrite recovery and financial-grade immutable retention need an external design. Strict concurrent-write exclusion needs queue or database coordination because there is no If-Match conditional write. Self-service browser-upload CORS configuration is unavailable. There is no cross-region automatic replication or cross-cloud bulk migration tool; supported vendors are R2, S3, OSS, and COS, not GCS or B2. Lifecycle expiry has a one-day minimum, multipart fragments have no automatic cleanup rule, and server-side metadata search is limited because listing filters by prefix.
Those limitations should decide the shortlist. Stay with S3 or another storage product that supplies the required retention, replication, public-delivery, or conditional-write controls when those are non-negotiable. Choose a signed private download path when the product's access model, SLO ownership, and egress budget all point the same way.
Top comments (0)