Auth App User Profile Images: Signed Access or Public CDN Delivery?

go dev.to

Short answer: keep user profile images private and issue short-lived signed URLs when the image is part of an authenticated experience; use a public CDN URL only when public retrieval is an explicit product requirement. The access decision matters more than the storage brand, and the durable value in your database should be an object key rather than an expiring URL.

That rule sounds obvious until an image moves through several systems. A logistics operator may see an avatar in a tenant dashboard, an internal dispatch screen, an email notification, and a public tracking page. Those consumers do not share the same identity context. Treating them as one “profile image” requirement is how a harmless upload feature turns into an accidental data-publication path.

What should an auth app do with profile images behind a tenant boundary?

Start with the viewer, not the file extension. If a user must belong to tenant acme-logistics before seeing another member's picture, the application should make that authorization decision and then grant access to the bytes. A signed URL is useful here because it carries a bounded credential to the object without making the object generally readable. The URL should be the delivery artifact, not the identity of the image.

Put an unguessable, user-scoped key in the profile record, for example tenants/acme-logistics/users/8f/avatar/3c2e.jpg. Keep the current key in the database. When the user replaces the image, write a new object, validate its media type and size, and update the pointer after the write succeeds. This gives replacement a clear state transition and avoids making cache invalidation depend on overwriting the same path.

The signed URL itself should be minted only after the requester's session and tenant membership have been checked. Its lifetime is a policy choice: short enough to limit unintended sharing, long enough to avoid re-signing every image during normal navigation. A presigned URL is still a bearer credential; anyone who obtains it can use it until it expires, so authorization at issuance is not a decorative step. See the object-storage documentation on presigned URLs for the mechanics and their expiration behavior.

One useful invariant is simple: the profile table stores a key, the authorization service decides who may receive a URL, and the storage layer serves the object named by that URL. If those responsibilities blur, incident review becomes harder because “private” can mean three different things.

Keys are durable.

Where does public CDN delivery fit, and where does it fail?

Public delivery is appropriate when anyone with the address is allowed to fetch the image. A public directory, a marketing page, or a tracking page intentionally shared with unauthenticated users may need that contract. A CDN can cache the bytes close to viewers and can keep the application out of the hot path after publication.

The catch is that public means public. An unguessable path is not an authorization policy, and removing an image from the database does not necessarily remove copies already cached by browsers or edge locations. If a tenant can revoke visibility, or if privacy rules differ by viewer, a public URL is not suitable. Use a private object plus signed delivery, or put an authorization-aware image proxy in front of storage.

A private design is also a poor fit for consumers that cannot refresh credentials. Email clients, social preview crawlers, and static documents may request an image long after a signed URL has expired and without an authenticated session. In that case, either publish a deliberately public derivative or accept a proxy with its own access and caching policy. Do not quietly make the source object public to rescue a consumer that had the wrong contract.

There is no universal winner. The right choice follows the visibility lifecycle: who can read the image today, who can read it after a membership change, and how quickly must revocation become observable?

The incident lesson is usually a cache and pointer problem

The failure mode I plan for is not a spectacular storage outage. It is a tenant-scoped authorization change that leaves an old avatar visible because a client saved a signed URL as if it were permanent. The image bytes are healthy. The product state is wrong.

Make the key canonical and derive delivery URLs at read time. On a cache hit, the browser can reuse a still-valid signed URL. On a cache miss or expiry, the application checks authorization again and issues a fresh one. Never use an expiring URL as the value that tells the UI which image belongs to the user; that value will eventually expire, and the resulting blank avatar will look like a missing object rather than a credential lifecycle event. The sequence matters during a tenant transfer: first the membership service changes the viewer's relationship, then the image endpoint evaluates that new relationship, and only then does it mint a URL. If a frontend has retained yesterday's URL, that URL may remain usable until its expiration, so the product's revocation promise must explicitly account for that bearer-credential window; deleting the database pointer alone cannot retroactively change a URL already issued to a browser.

The pointer is the source of truth.

The write path needs the same discipline. Generate a fresh identifier for each replacement, reject content that is not an allowed image before it reaches shared storage, and make the database pointer update conditional on the expected current version when concurrent profile edits matter. Delete old objects asynchronously only after the new pointer is durable, with a retention rule that matches the tenant's recovery needs.

Here is the part I keep small enough to test without a storage emulator: key construction and validation. The storage adapter can then receive an already-authorized key and a requested operation.

package avatar

import (
    "fmt"
    "regexp"
)

var tenantIDPattern = regexp.MustCompile(`^[a-z0-9-]{1,63}$`)

func objectKey(tenantID, userID, imageID string) (string, error) {
    if !tenantIDPattern.MatchString(tenantID) || userID == "" || imageID == "" {
        return "", fmt.Errorf("invalid avatar identity")
    }
    return fmt.Sprintf("tenants/%s/users/%s/avatar/%s", tenantID, userID, imageID), nil
}
Enter fullscreen mode Exit fullscreen mode

The important test cases are authorization boundaries, replacement races, expired credentials, and a revoked tenant membership. I would also test the visible fallback: keep the prior image briefly, show a neutral placeholder, or retry according to the product's SLO. A green object-storage dashboard does not prove that the avatar feature is healthy if signing latency or authorization failures are outside its budget.

How should capacity planning and SLOs shape signed URL delivery?

Measure three stages separately: authorization, URL issuance, and byte delivery. The first two are application work; the third may be served by a browser cache, an edge cache, a proxy, or object storage. Combining them into one latency metric hides the actual bottleneck and makes capacity planning depend on the wrong variable.

During a cache-cold event, signing traffic can rise even when the image bytes are small. Set an objective for the signing endpoint, record issuance latency and rejected requests, and decide what the page does when the issuer is unavailable. A prior image may be acceptable for a short period in a dashboard; it may be unacceptable after a user explicitly revokes visibility. Your mileage may vary because the right fallback depends on the privacy contract, not on a generic TTL recommendation.

For public CDN delivery, plan around cache invalidation and revocation instead. Versioned keys make new uploads easy to distinguish, but they also leave old public objects reachable unless deletion and cache behavior are defined. For signed delivery, plan around credential refresh and authorization load. Both options need observability for image load failures, not just storage request counts.

Cost is part of the decision, but it is not the decision. Object storage pricing commonly separates stored data, requests, and data transfer, so model the access pattern that the product actually creates rather than comparing a single per-gigabyte number. A cheaper byte path that creates a larger signing fleet or a difficult privacy incident is not a cheaper system.

A buy-versus-build decision table

Approach Fits when Main trade-off Operational question
Private objects plus signed URLs Reads require session and tenant authorization The issuer must remain available and URLs expire What is the refresh and fallback policy?
Public objects plus CDN Anyone may retrieve the image Revocation and cache invalidation are weaker How quickly must removal take effect?
Authorization-aware image proxy Consumers cannot handle signed URLs The proxy becomes a byte-delivery service Can it meet the image SLO under cache-cold load?
Self-hosted object storage Residency or control outweighs delivery simplicity The team owns durability, upgrades, and paging Which operator owns replication and recovery?

The choice is not suitable for every workload. If profile images are genuinely public and high-volume, a private issuer in front of every read adds needless coordination; choose a public delivery path with explicit cache and removal rules. If images expose tenant-sensitive information or membership changes must revoke access, a public CDN is the wrong default even when it is easier to embed. Stick with the simpler public design only when its visibility contract is stable and documented.

My decision record would include the object-key format, allowed readers, URL lifetime, revocation expectation, cache behavior, upload validation, deletion retention, and the SLO for each stage. That is enough to review the design six months later without relying on whoever first wired the avatar picker.

References

Source: dev.to

arrow_back Back to Tutorials