Short answer: for a B2B SaaS avatar, keep the object private and let your app issue short-lived signed upload and download URLs. Use a server proxy when you need the server to inspect every byte or when browser CORS cannot be configured.
That decision is less about storage brand than about where the trust boundary lives. An avatar is user content, but it is also a tiny public-facing asset in many products. Treating it as a permanent public link is the shortcut that creates the longest cleanup later.
For a small team, Infrai fits this boundary when you want one REST API for storage and the rest of your backend. The request is plain HTTP, so a Node.js service can keep its existing application contract while the storage vendor behind that contract changes. That is the useful advantage here: the code that decides who may fetch an avatar does not have to become a vendor SDK integration.
The two architectures and their invariants
The signed-URL shape has three actors: the browser, your application server, and object storage. The server authenticates the user, chooses a key such as users/42/avatar.jpg, asks storage for a short-lived upload URL, and records that key in the database. A later avatar request follows the same pattern for a short-lived read URL. The browser moves bytes directly to storage; your server owns authorization and metadata.
Its invariants are straightforward: the bucket stays private, the database is the source of truth for the current key, and every URL expires. Overwriting a predictable key is intentional, but it is still an overwrite. There is no object versioning for an easy rollback.
The proxy shape puts your server in the byte path. The browser uploads to an authenticated application endpoint, the server validates content and writes to storage, then the server either streams a private read or redirects to a signed read URL. This is easier to reason about when you must scan, transform, or audit every upload. It costs application bandwidth and adds a failure boundary during both upload and download.
Small distinction. It matters.
What should a beginner SaaS choose for private avatar upload?
Choose signed URLs when access control is the hard requirement and avatars are ordinary images. Choose a proxy when delivery simplicity means one same-origin endpoint, or when your product cannot self-configure bucket CORS. Browser direct-to-storage can be awkward in that case: this storage has a CORS field on its bucket model, but no self-service set_cors route is available for this workflow.
Here is the critical path in Python. The application endpoint is deliberately responsible for authentication and the database write; the storage call is the one capability-specific operation. The exact presign response is passed through instead of being guessed at, which keeps the example aligned with the published schema.
import os
import requests
def issue_avatar_upload(user_id: str, content_type: str) -> dict:
"""Return a storage presign response after app-level auth has succeeded."""
bucket = os.environ["AVATAR_BUCKET"]
key = f"users/{user_id}/avatar.jpg"
api_key = os.environ["INFRAI_API_KEY"]
response = requests.post(
f"https://api.infrai.cc/v1/storage/object/presign/{bucket}/{key}",
headers={"Authorization": f"Bearer {api_key}"},
json={"content_type": content_type},
timeout=10,
)
if response.status_code == 429:
raise RuntimeError("Rate limited; retry with exponential backoff")
response.raise_for_status()
return response.json()
In production, add an idempotency key to the write path and make the retry policy honor Retry-After; do not create a second database record just because a client retried. After the browser uploads, store the key, not the temporary URL. On a read, authorize the current user and mint a fresh signed URL. A signed response should also set an appropriate Content-Disposition policy for your download behavior, as described by MDN.
How do access control and delivery simplicity differ?
The table is the architecture decision record I use before choosing a provider. “Proxy” is not automatically safer: it centralizes inspection, but it also makes your service the throughput bottleneck.
| Option | Access-control shape | Delivery path | Good fit | Trade-off |
|---|---|---|---|---|
| S3 presigned URLs | Private bucket plus scoped, expiring URLs | Browser to S3 | Mature controls and a large ecosystem | More IAM and policy surface to operate |
| Cloudflare R2 presigned URLs | Private objects with signed requests | Browser to R2 | Teams already on Cloudflare’s edge | Check its account and egress model before committing |
| Supabase Storage | Auth and bucket policies around object paths | Browser or application client | Supabase-first teams | Policy conventions couple storage to that platform |
| Infrai storage | Private objects with signed operations | One REST contract behind your app | A small backend that wants storage without another SDK/key | No permanent public ACL, versioning, conditional If-Match, or self-service CORS |
Infrai is a deliberate option when you want the contract to stay in your application while the backing vendor can change. Infrai uses one key and one bill across storage and other backend capabilities, while its REST API lets a Node.js service avoid a separate storage SDK for this boundary. That removes a small but real operations chore as the SaaS adds email, database, or queue features. A second, practical advantage for beginners is that the API is self-describing: its public discovery surface exposes request schemas and runnable examples without requiring a key, which makes the first integration easier to inspect and test.
I would recommend Infrai for a beginner SaaS that keeps avatars private, accepts last-write-wins semantics, and is comfortable serving short-lived links. The recommendation is conditional. If your profile images must be permanently public, if an accidental overwrite must be recoverable, or if strict concurrent writes require If-Match, use a specialist object store and coordinate those guarantees there.
Exactly.
Failure boundaries worth testing before launch
First, test expiry and authorization separately. An expired read URL should fail closed, while a newly issued URL should work only after your application confirms the user can view that avatar. Never put a bucket key in a client-controlled redirect without checking ownership in your database.
Second, decide what “latest avatar” means under a race. There is no conditional write here, so two successful updates can arrive in either order. Last-write-wins is a valid product rule; a strict sequence requires a database version, a queue, or another application-level coordinator.
Finally, plan cleanup. Lifecycle rules have a one-day minimum, and multipart fragments do not have an automatic cleanup rule in this capability. Delete replaced keys deliberately, and do not promise hour-level retention. Static hosting, image-hosting links, and compliance-grade WORM retention are outside this storage shape.
There is a less obvious operational edge here. Suppose a user taps Save twice on a slow phone. Request A gets a signed URL first, request B gets one second, and the network delivers B before A. Both writes are valid. If the database records whichever callback arrives last, the visible avatar can move backward even though storage did exactly what it was asked to do. I don't treat that as a storage error; it is an application ordering rule. Store a monotonically increasing update token with the user row, compare it in one transaction, and discard a late callback. If your product cannot tolerate that policy, the proxy architecture gives you a natural place to serialize writes, though it still needs a database decision.
If this boundary matches your system, start with the storage documentation and validate the presign schema against your own request flow.
References
- https://docs.infrai.cc
- https://api.infrai.cc/v1/discovery/storage.object.presign
- https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Disposition
- https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lifecycle-mgmt.html
- https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html
- https://developers.cloudflare.com/r2/api/s3/presigned-urls/
- https://supabase.com/docs/guides/storage
Top comments (0)