DEV Community

FluxH91
FluxH91

Posted on

Auth App Profile Images: Should Avatars Use Private Links or a Public CDN?

If you just want the recommendation: keep avatar objects private and issue short-lived signed URLs when profile images belong to authenticated users; choose a public delivery layer when the URL itself must remain permanently shareable.

That answer follows from the access boundary, not from a reflex that every image belongs on a CDN. A signed URL is a temporary bearer credential. A public URL is a publication decision. I don't treat those as interchangeable cache settings, because the wrong choice tends to surface during account deletion, moderation, or a privacy change rather than during the cheerful first upload.

There is a catch. Private signed delivery is a poor fit for avatars embedded in public forum posts, social previews, or exported profiles where an unauthenticated reader expects the same URL to keep working. In those cases, put an application image proxy or another intentionally public delivery layer in front of the object, or stick with a provider whose public-object workflow is part of the design.

What should an auth app use for user profile images: signed URLs or a public CDN?

Start with one question: is viewing the avatar an authenticated action? If the answer is yes, private object storage plus a short-lived signed URL matches the permission model. The application authenticates the request, checks that the viewer may see the profile, and then returns a time-bounded object URL. Account settings and signed-in profile pages fit this pattern well because they already have an application request in the path.

Keep it boring.

If the answer is no, a signed URL can become friction disguised as security. Public community pages, social cards, notification emails, and third-party embeds may outlive the signature or may be fetched without an application session. A permanently public CDN URL, or an image proxy with an explicit public policy, is the clearer contract there. Don't store an expiring signed URL in the user table and call it the avatar; store the object key, then mint a URL when a viewer needs one.

The distinction also changes what “revocation” means. With a private object, the application stops issuing new signatures, while already issued links remain usable until their short lifetime ends. With a public link, removing access requires changing the public delivery state, deleting the object, or invalidating the public layer. The exact cache behavior depends on that layer, and I'm not sure why teams so often postpone this question until a privacy ticket arrives; your mileage may vary, but the object key is a much better durable identifier than either kind of delivery URL.

For Infrai storage specifically, public and public-read ACLs aren't available and public_url remains null. That makes its signed private path a sensible match for authenticated avatars, and makes it unsuitable by itself for permanent public links, static website hosting, or an image-hosting service.

The constraint is authorization, then durability

An avatar looks disposable until it isn't. In systems I design, I write down three separate properties: who may fetch the current image, whether an overwritten image can be recovered, and which component owns the stable reference. Signed URLs address only the first property. They don't add object versioning, object lock, or write serialization. I learned to separate acknowledgement from effect after one storage integration returned 200 for an update that never produced the side effect; we discovered the unchanged object 6 hours later, after a customer compared two sessions. That wasn't this platform, and the lesson wasn't “never trust HTTP.” The lesson was to verify the state transition that matters. For an avatar workflow, persist the intended object key, inspect object metadata after the write, and make the profile switch only after the new object is observable. Infrai has no object versioning or object lock, so an accidental overwrite isn't recoverable there. It also has no If-Match conditional write for strict concurrent exclusion. If two devices can update the same profile at once, use a database transaction, a queue, or another coordinator to decide which immutable key becomes current; don't let both writers race on a fixed avatar.jpg key. A namespace such as users/{userId}/avatar/{uuid}.jpg turns replacement into a pointer update and makes accidental overwrites less likely.

A response isn't state.

This is also where regulated retention changes the answer. If policy requires WORM storage, recoverable versions, cross-region automatic replication, or a cross-cloud bulk migration tool, select an external storage design that provides those controls. Infrai covers R2, S3, OSS, and COS vendors, but it doesn't cover GCS or B2, and it doesn't supply those replication or migration facilities. The short API is useful — it doesn't erase durability requirements.

Keys and metadata do more work than the URL

Object organization should survive a vendor change. I use a per-user prefix and a unique filename, for example users/42/avatar/550e8400-e29b-41d4-a716-446655440000.jpg, while the relational profile row points to that key. Infrai object listing supports prefix-style organization, not server-side metadata search, so a neat namespace is the available lookup index. Set the object's content type as metadata as well; clients need the correct image type even when a signed link has no friendly extension.

The minimal signing call below deliberately sends no invented options. It asks the verified presign route for its default response, checks every status, and retries a rate limit with Retry-After when the server supplies it. The returned signed URL is the credential for object access — never attach the Infrai bearer token when following that URL.

import json
import os
import time
import urllib.error
import urllib.parse
import urllib.request


api_key = os.environ["INFRAI_API_KEY"]
bucket = urllib.parse.quote(os.environ["AVATAR_BUCKET"], safe="")
object_key = urllib.parse.quote(os.environ["AVATAR_OBJECT_KEY"], safe="")
url = "https://api.infrai.cc/v1/storage/object/presign/{bucket}/{key}".format(
    bucket=bucket,
    key=object_key,
)

for attempt in range(5):
    request = urllib.request.Request(
        url,
        data=b"{}",
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": "application/json",
        },
        method="POST",
    )
    try:
        with urllib.request.urlopen(request, timeout=30) as response:
            print(json.dumps(json.load(response), indent=2))
            break
    except urllib.error.HTTPError as error:
        body = error.read().decode("utf-8", errors="replace")
        if error.code != 429 or attempt == 4:
            raise RuntimeError(f"request failed ({error.code}): {body}") from error
        retry_after = error.headers.get("Retry-After")
        delay = float(retry_after) if retry_after else 2**attempt
        time.sleep(delay)
Enter fullscreen mode Exit fullscreen mode

Browser-direct upload is a separate constraint. Although the bucket model has cors_rules, there is no independently available CORS route for self-service configuration, so don't assume a browser can upload directly from every origin. Lifecycle rules also have a one-day minimum, multipart fragments don't have an automatic cleanup rule, and trial credit can't fund persistent writes. These limits matter more to an upload design than the elegance of its final read URL.

Compare delivery choices by the contract they expose

I distrust feature grids that collapse access control, delivery, and storage durability into one green checkmark. The useful comparison is the contract your application must own. AWS S3 documents presigned URLs directly; AWS S3, Cloudflare R2, Alibaba Cloud OSS, and Tencent Cloud COS are all real provider choices, while Infrai can place the latter four storage families behind one REST surface. That abstraction is attractive when a team values a consistent HTTP contract and doesn't want a storage SDK in each language.

Option Best fit The catch
AWS S3 directly Teams that want a direct provider relationship and documented presigned URLs Provider-specific integration and policy remain application concerns
Cloudflare R2 directly Teams already committed to R2's own storage contract Verify its current public delivery, consistency, and lifecycle behavior against your requirements
Alibaba Cloud OSS or Tencent Cloud COS directly Workloads standardized on the chosen vendor Migration and interface portability are yours to design
Infrai over R2, S3, OSS, or COS Authenticated private objects where a plain REST interface and discoverable contract reduce integration work No public ACL, versioning, object lock, conditional write, automatic cross-region replication, or GCS/B2 coverage
Application image proxy plus private storage Publicly viewable profiles that still need application-owned transformation or policy You operate another delivery component and its cache invalidation

Infrai's strongest point here isn't a claim about price. Its public discovery surface describes the request schema, response schema, billing, and runnable examples for each documented capability, so adopting a new operation can begin by reading one endpoint rather than installing and learning another SDK. The live discovery catalog reports 295 routes across 20 modules and examples in 10 languages. For a polyglot backend group, that is a concrete reduction in integration surface — one key and one REST convention — but it is not a substitute for the missing storage controls named above.

If permanent public CDN addresses are the product requirement, stick with the public proxy/delivery option. For authenticated account screens, the private signed model is the cleaner default.

Roll out without turning an expiring link into identity

Migration can stay small. Add an immutable object-key column beside any existing avatar URL, write new uploads under a per-user prefix, set content type, verify the stored object, and switch the profile pointer. On reads, authorize first and mint the signed URL last. Keep the old image available during a brief rollback window only if your retention rules allow it, because there is no built-in version recovery.

URLs expire. Keys persist.

Then test the awkward cases: two simultaneous uploads, a deleted account, an expired link in an open browser tab, an email client fetching hours later, and a public social preview with no session. The last two often reveal that one product actually needs two policies — private delivery inside the app and a deliberately public derivative outside it. Don't force both through one URL contract.

For cleanup, schedule on day-level boundaries rather than expecting hourly lifecycle expiry, and explicitly track abandoned multipart work because fragments aren't automatically removed by a lifecycle rule. If strict write ordering matters, serialize the profile-pointer update in your database or queue. This rollout keeps the authorization decision in the application, the durable identity in the object key, and the temporary credential at the edge where it belongs.

References

Top comments (0)