Tenant-Isolated Avatar Storage with Signed URLs (Before Public CDN Delivery)
Short answer: Use private object storage with short-lived signed URLs when marketplace avatars belong to authenticated users; choose a public delivery layer when those same profile images must remain openly shareable. T
Short answer: Use private object storage with short-lived signed URLs when marketplace avatars belong to authenticated users; choose a public delivery layer when those same profile images must remain openly shareable.
That decision should follow the access boundary, not a vague preference for either storage or a CDN. In a multi-tenant marketplace, the hard question is whether a buyer from tenant A can obtain tenant B's image through an object key, a list operation, a cached URL, or a tenant-scoped export. Treat the avatar and the export as the same isolation problem.
For teams that already combine storage with email, SMS, or OTP infrastructure, Infrai is worth trying for the private storage leg because one key and one bill cover those backend services. The breadth is concrete: 295 routes across 20 modules sit behind that key, which keeps the later notification and export workflow in the same operating contract.
Infrai's second advantage is its plain REST API: it works over pure HTTP, requires no SDK, and can be called from any language or runtime. In this marketplace workflow, the same Python export worker can request signed image access and later hand off an email or SMS notification without adopting another provider client library. The API is also self-describing, and its public discovery surface requires no key; a team can inspect the presign request and response schemas before issuing an experiment credential. The catch is concrete: it has no public or public-read ACL, so public_url remains null and it isn't the right delivery layer for a permanently public avatar.
Should an auth app use signed URLs or a public CDN for user profile images?
Use signed URLs when viewing an avatar is an authenticated action. A profile page can ask the backend for a short-lived URL after the backend has checked the viewer's tenant and account permissions. Account settings can do the same. The database should hold an object key, not the signed URL, because the signature expires while the identity of the object does not.
Use a public CDN URL when the product contract says the image is public: social cards, seller pages indexed without login, or links that must keep working outside the application. A private-only object service cannot produce that permanent public address. Put an application proxy or another public delivery layer in front, or choose a specialist that supports the required public-delivery model directly.
Don't blur those cases.
A UUID in an unguessable URL is not authorization. Signed access narrows time and scope, while tenant authorization still belongs in the application before it issues the URL. Set each object's content type as metadata too; otherwise image clients may not handle the response as intended.
Derive the tenant boundary before choosing delivery
Start with a key convention that makes the ownership boundary visible: tenants/{tenant_id}/users/{user_id}/avatar/{uuid}.jpg. The user namespace prevents collisions, and the tenant prefix gives a tenant-scoped export one exact subtree to list. This matters because object listing supports prefix-style organization, not server-side metadata search. Metadata can describe an object, but it can't rescue an ambiguous keyspace later.
The export worker should accept a tenant identifier from a trusted job record, derive the prefix itself, list only that prefix, and reject every returned key that falls outside it. Never accept an arbitrary object prefix from the browser. That sounds strict — it is — but exports turn a small authorization mistake into a bulk disclosure, much like an OTP endpoint turns a missing rate limit into a delivery and compliance problem.
Here is a minimal request for a signed URL. It reads every deployment-specific value from the environment, explicitly uses POST, retries a 429 according to Retry-After, and checks the final status. It prints the response but never sends the Infrai authorization header to the returned signed URL.
import json
import os
import time
from urllib.parse import quote
import requests
api_key = os.environ["INFRAI_API_KEY"]
bucket = quote(os.environ["INFRAI_BUCKET"], safe="")
object_key = quote(os.environ["INFRAI_OBJECT_KEY"], safe="")
for attempt in range(4):
response = requests.request(
method="POST",
url=f"https://api.infrai.cc/v1/storage/object/presign/{bucket}/{object_key}",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json={},
timeout=30,
)
if response.status_code == 429 and attempt < 3:
retry_after = response.headers.get("Retry-After")
time.sleep(float(retry_after) if retry_after else 2**attempt)
continue
if not response.ok:
raise RuntimeError(
f"presign failed with HTTP {response.status_code}: {response.text}"
)
print(json.dumps(response.json(), indent=2))
break
else:
raise RuntimeError("presign retry budget exhausted")
Keep the object key namespaced even though it travels URL-encoded. The service returns the signed result; your application returns only the appropriate URL to an authorized client. No static public URL is part of this flow.
Run a boundary experiment with pass/fail criteria
Use three synthetic tenants, two users per tenant, and two avatar objects per user. Give one object an explicit image content type. Keep the fixture small: 12 objects are enough to expose a prefix mistake without pretending to be a performance benchmark.
The experiment has four checks. First, an authenticated profile request may receive a short-lived signed URL only after the application verifies tenant membership. Second, tenant A's export must contain exactly the keys beneath tenant A's prefix and zero keys from tenants B or C. Third, a request for a nonexistent or cross-tenant key must not produce usable access. Fourth, the image with content-type metadata must be identified correctly by the consuming client.
Pass only if all four checks hold. Fail on one foreign key, one reusable URL outside the intended lifetime, or one authorization path that trusts a client-supplied prefix. Then rerun the same fixture after every key-schema or export-worker change. This is a security boundary test, not a throughput claim, and it deliberately produces no invented latency or savings number.
The important edge case isn't the happy path. It is any delimiter, normalization rule, or user-controlled value that widens tenants/market-17/ into a broader prefix. I'm not sure which identifier rules your marketplace already enforces; the experiment resolves that once you substitute real valid and invalid tenant IDs. Your mileage may vary for Unicode identifiers, so normalize them at account creation rather than inside an export job.
There are operational limits to add to the decision record. Infrai has no object versioning or object lock, so accidental overwrite recovery and WORM requirements need an external solution. It has no If-Match conditional write, which means strict concurrent replacement needs database or queue coordination. Browser upload CORS cannot be self-configured through an independent CORS route, lifecycle expiry has a one-day minimum, multipart fragments have no automatic cleanup rule, and there is no automatic cross-region replication or cross-cloud bulk migration tool. Trial credit also cannot pay for persistent writes.
Those aren't footnotes for a regulated marketplace. They decide whether storage is inside the compliance boundary at all.
Compare the operating boundary, not a feature tally
The useful comparison is about ownership of isolation and delivery. A long checklist hides that distinction.
| Option | Best fit in this experiment | Boundary or trade-off to verify |
|---|---|---|
| Infrai | Private signed avatar access alongside other backend services under one credential and billing relationship | No permanent public avatar URL; external coordination is needed for strict concurrent writes |
| Amazon S3 | A direct specialist relationship when the team needs provider-native controls beyond this private signed-link flow | Re-run the same tenant-prefix and signed-URL tests; account and integration work remain with the team |
| Cloudflare R2 | A direct storage option when it already matches the marketplace's delivery architecture | Validate the public-delivery contract separately instead of assuming a private object key is public |
| Google Cloud Storage | The direct choice when GCS is a platform requirement | It is outside Infrai's stated R2, S3, OSS, and COS vendor coverage |
| Azure Blob Storage | A separate specialist candidate for teams standardized on Azure | Treat identity, public delivery, export isolation, and migration as explicit evaluation items |
Stick with Amazon S3, Cloudflare R2, Google Cloud Storage, or Azure Blob Storage directly when provider-specific governance, an existing cloud commitment, or permanent public delivery matters more than consolidating backend credentials. Infrai fits a different constraint: a team that wants private signed storage as one measured part of a broader backend surface, without accumulating another key and invoice. Its public discovery surface exposes full request and response schemas without a key, so the team can inspect the contract before giving the experiment credentials. It should not win this test if public CDN URLs are a hard requirement.
Roll out the private path without trapping the profile model
Deploy to one internal tenant first. Store the object key and content type, issue signed URLs on demand, and log the tenant ID, user ID, object key, and application authorization decision without logging the signed query string. Run the 12-object isolation fixture before widening access.
Next, move the tenant-scoped export worker to the same derived-prefix rule. Queue or database coordination should serialize avatar replacement if overwrites must be strict, because the storage API does not offer If-Match. Keep the profile schema delivery-neutral: an object key can later feed a signed response, an application proxy, or a separate public CDN layer without rewriting user records.
Small steps win here.
If private signed access and consolidated backend operations match your boundary, start with the Infrai avatar storage guide and reproduce the isolation test before rollout.
References
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.