Short answer: A marketplace avatar needs a public delivery contract for a durable public URL; private object storage needs a refreshed signed download URL, and its expiry says the access grant ended, not that the image was deleted.
For a marketplace avatar, retention and deletion should decide the access model before the upload API does: a stable anonymous image address needs a deliberately public delivery contract, while a private object should be reached through a short-lived signed download capability. A public-looking URL cannot override a private bucket, and an expired signed URL is not evidence that the object was deleted.
That distinction matters most when a seller closes an account or replaces a profile image. The product needs to answer two separate questions: who may read the bytes now, and how long must those bytes remain recoverable? I have seen these questions collapse into one avatar_url column, which makes a routine retention change look like a rendering outage.
What should a marketplace do when an avatar public URL meets private object storage?
Treat the object key as identity and the URL as a delivery decision. Store a bucket and an opaque key, not a signed download URL, in the marketplace database. The key can survive a URL refresh; the URL is only a temporary authorization to read that key.
For an authenticated marketplace surface, the request path is straightforward: authorize the viewer, mint a signed GET URL, and return it with a lifetime that matches the session and cache policy. When the signature expires, the client requests a fresh representation. The object can still exist, and its retention clock has not changed.
For an anonymous seller page, a private object storage URL is the wrong product contract unless a delivery service sits in front of it and owns the anonymous-read policy. CORS is a separate browser permission: it controls which origins may read a response, not whether an object is public or whether a signature is valid. Configuring CORS cannot make a private object address permanent.
The rule is small. It prevents a large class of bad fixes.
The deletion incident is really two clocks
Imagine a seller changes an avatar while a cached profile page still references the old object. The application deletes the old key immediately, but the browser has a signed URL that remains valid for another ten minutes. A moderation worker may process the replacement between those two events, while a profile renderer retries the old address and emits a generic image-load metric. Depending on the delivery and cache headers, the viewer may see the old bytes, a stale cache hit, or an authorization failure; the profile may look healthy in one region and broken in another if caches do not share the same object state. None of those observations alone tells the operator whether deletion succeeded, whether the signer issued a capability after retirement, or whether the client merely needs a fresh representation. The incident review therefore needs object-key audit events, signer decisions, cache headers, and deletion-queue timestamps correlated by request ID.
Use two explicit clocks: an access clock for the signed URL and a retention clock for the object. A deletion request should mark the avatar as retired in the database, stop minting new read capabilities, enqueue object deletion, and record the deletion result. A separate reconciliation job compares retired keys with storage listings. That gives the SRE team an auditable state transition instead of relying on a browser error message.
The marketplace also needs a policy for moderation holds, legal preservation, and replacement cleanup. A seller's replacement is usually eligible for deletion; a disputed listing image may not be. The storage layer cannot infer those business states from an object key. Keep the policy in the application, and make deletion idempotent so a retry does not create a second semantic event.
The sharp edge is caching. If a signed URL is copied into a long-lived CDN cache, its cached bytes can outlive the authorization that fetched them. That may be acceptable for a public avatar, but it is a security defect for a private one. Set cache behavior from the visibility policy, then test it with an expired URL, a revoked avatar, and a replacement object.
A buy-versus-build review for avatar delivery
The decision is operational, not cosmetic. A managed object store can reduce the amount of storage machinery the platform team owns, but the application still owns authorization, key lifecycle, deletion semantics, and the SLO for an avatar appearing on a profile. Self-hosting can provide more control over retention and network placement, but it transfers durability, replication, capacity, and incident response into the team's on-call budget.
| Approach | Fits when | Trade-off to accept |
|---|---|---|
| Private object storage plus application signing | Authenticated pages need bounded reads | The signer is on the read dependency path and must be capacity-planned |
| Public delivery layer backed by object storage | Anonymous profiles need durable image addresses | Revocation and cache invalidation need a defined control plane |
| Self-hosted object service | Data placement and retention controls justify operating storage | The team owns durability, upgrades, replication, and recovery testing |
| Application-proxied bytes | Traffic is modest or policy requires central inspection | The application absorbs bandwidth, latency, and egress pressure |
No option is universally correct. Choose a public delivery design when anonymous reads and static pages are release requirements. Choose private signed access when authorization and bounded exposure matter more than a permanent URL. Choose self-hosting only when the team can carry its storage SLO, including restore drills, rather than counting control as free capacity.
I'm not sure which boundary will dominate until the team measures anonymous-read volume, signer latency, deletion lag, and cache hit rate together. Your mileage may vary by marketplace geography and moderation policy; those measurements resolve the uncertainty better than a provider comparison table.
The preventative path belongs in code and monitoring
The handler below illustrates the important ordering: persist the durable identity, then create a read capability only after authorization. The storage client is deliberately an interface, so the deletion and retention tests do not depend on a particular provider.
package avatar import ( "context" "errors" "time" ) type ObjectStore interface { Put(ctx context.Context, key string, data []byte) error Delete(ctx context.Context, key string) error SignedGet(ctx context.Context, key string, ttl time.Duration) (string, error) } type Avatar struct { UserID string Bucket string Key string State string } type Repository interface { SaveAvatar(ctx context.Context, avatar Avatar) error FindAvatar(ctx context.Context, userID string) (Avatar, error) } func ReadAvatar(ctx context.Context, store ObjectStore, repo Repository, userID string, viewerID string) (string, error) { avatar, err := repo.FindAvatar(ctx, userID) if err != nil { return "", err } if avatar.State != "active" || viewerID == "" { return "", errors.New("avatar is not available to this viewer") } return store.SignedGet(ctx, avatar.Key, 10*time.Minute) } The ten-minute value is an example policy, not a universal setting. The useful tests assert behavior: a retired avatar is not signed, a replacement does not delete the new key, a repeated deletion is harmless, and an expired capability causes a refresh rather than a database rewrite. Monitor the signer error rate, p95 signing latency, object deletion lag, stale-avatar rate, and the percentage of profile renders that need a refresh. Put those signals beside the profile-rendering SLO.
One production mistake worth naming: logging the complete signed URL turns a temporary capability into an incident-response artifact that may be copied into chat, traces, or ticket systems. Log the opaque key and a request identifier instead. The access decision should be observable without recording the credential used to make it.
A decision rule for the platform roadmap
Write the retention and visibility contract in the product specification before choosing a bucket setting. It should state whether anonymous visitors may read the avatar, what deletion means, how quickly reads must stop, whether moderation can hold an object, and what happens to cached bytes.
Then model the dependency chain: browser, CDN if present, signer, authorization store, and object store. A profile SLO that depends on all of them needs a budget for each; a public delivery path removes the signer from the hot path but introduces cache invalidation and revocation work. Either design can be sound when the contract, tests, and operating ownership agree.
The durable answer is not a clever URL. It is a durable object identity paired with an explicit access and retention policy.

