Short answer: use a presigned browser upload for small avatars only when the application controls the object key, the storage policy isolates tenants, and the deployed origin passes a real CORS test; otherwise, proxy the bytes through the application and accept the bandwidth cost.

The easiest setup is rarely the one with the fewest lines of React. In an e-commerce system, an avatar is a small file attached to a customer or staff identity, and the dangerous part is not moving those bytes. It is proving that a tenant cannot guess, overwrite, or retrieve another tenant's object while deployments, retries, and stale browser tabs are doing their normal work.

What should a React or Next.js team verify before choosing private avatar object storage?

Start with the boundary. The browser should never choose a tenant prefix, authorize a bucket, or decide which account owns an object. A Next.js route or another authenticated application service should derive the tenant ID from the session, validate the requested media type and size, and mint a short-lived upload capability for one server-generated key. The client receives only the capability and the key it is allowed to use.

Use an opaque key such as tenant/{tenantID}/avatar/{accountID}/{revisionID}.webp, where each identifier comes from trusted application state or a cryptographically strong generator. Do not use an email address, an untrusted filename, or a path copied from the browser. Treat that object key as an authorization boundary, not a naming convenience.

For an avatar flow, the application contract can stay small:

  1. Authenticate the caller and resolve the tenant on the server.
  2. Validate size, declared type, filename policy, and the intended account.
  3. Create a new object key and a short-lived presigned upload capability.
  4. Let the browser upload directly, then verify the write before updating the account record.
  5. Mint a separate private read capability when the profile page needs the image.

The application must treat the storage response as an event to verify, not as proof that the UI behaved correctly. A successful request to mint a URL is not a successful object write. A successful object write is not, by itself, permission to attach that object to a different account.

Test the boundary.

The runbook: isolate the tenant before sending bytes

The strongest design makes a bad key unusable even if a caller obtains it. Put tenant restrictions in the server-side policy and, where the storage system permits it, in the capability's object scope. Keep the bucket private. A private bucket is a useful default, but it does not repair an application that signs arbitrary keys.

The direct path has a browser-specific gate: CORS. Test from the exact production origin with the same method, content type, and request headers used by the React client. A local development origin proves only the local origin. Capture the preflight result in the release checklist, because a domain change can turn a previously valid upload into a browser policy failure before the application sees a response.

If the team cannot control or verify that policy, use an application proxy. That path adds application-server bandwidth and request lifetime to the capacity plan, but it gives the service one place to enforce size limits, inspect content, rate-limit callers, and record audit context. For small avatars, that operational clarity can be worth more than removing one data hop.

The two choices should expose the same application-level operation. A feature flag can switch the data path while the profile service still receives a validated object key. That is a practical rollback: disable direct upload, keep the account contract, and avoid a schema migration during an incident.

Decision Direct browser upload Application proxy
Tenant isolation Enforce before signing; constrain one key Enforce before accepting the body and before writing
Browser dependency Production CORS and upload headers Only the browser-to-application request needs CORS
Capacity concern Storage ingress and signing traffic Application bandwidth, memory, connection limits
Best fit Small files and a verified browser policy Uncertain CORS, stricter inspection, or simpler incident response
Poor fit Workflows needing server-side transformation first High-volume or large-file traffic that the app cannot absorb

There is no universal winner. The data path changes which team owns the failure.

package main import ( "errors" "fmt" "mime" "path" "strings" ) var allowedImageTypes = map[string]bool{ "image/jpeg": true, "image/png": true, "image/webp": true, } func validateAvatar(tenantID, accountID, objectKey, contentType string, size int64) error { if tenantID == "" || accountID == "" { return errors.New("tenant and account identity are required") } if size <= 0 || size > 5<<20 { return errors.New("avatar size is outside the application limit") } mediaType, _, err := mime.ParseMediaType(contentType) if err != nil || !allowedImageTypes[mediaType] { return errors.New("avatar content type is not allowed") } wantPrefix := fmt.Sprintf("tenant/%s/avatar/%s/", tenantID, accountID) cleanKey := path.Clean("/" + objectKey)[1:] if cleanKey != objectKey || !strings.HasPrefix(objectKey, wantPrefix) { return errors.New("object key is outside the account scope") } return nil } func main() { if err := validateAvatar("shop-42", "acct-7", "tenant/shop-42/avatar/acct-7/rev-9.webp", "image/webp", 180_000); err != nil { panic(err) } fmt.Println("avatar request is within the tenant boundary") } 

The 5 MiB limit above is an application policy example, not a storage guarantee; set it from observed avatar dimensions, decoder limits, and the service's memory budget. Also inspect the decoded image where the threat model requires it. A filename and a MIME header are claims supplied by the client, and OWASP's file-upload guidance explains why extension checks alone are not a sufficient control.

How do verification, SLOs, and rollback work for presigned uploads?

Verification needs two distinct records: an upload attempt and an account attachment. Record the tenant, account, object key, request ID, size, declared type, and outcome without recording the file contents. Attach the key only after the client or a server-side verifier confirms a successful storage write. If asynchronous scanning is required, put the object in a quarantine prefix and make the account point to it only after the scan state is acceptable.

Define an SLO around the user-visible operation, not just the URL-minting endpoint. For example, measure the time from the authenticated upload request to a confirmed object and usable private read capability. Break failures into capability issuance, browser preflight, upload response, verification, and account update. A single latency number hides which boundary is consuming the error budget. During a release rehearsal, use a test tenant with two accounts and run the same sequence from the production hostname: sign for account A, upload a deliberately small valid image, refresh the profile, request account B's image with account A's session, retry the upload after a client-side timeout, then revoke the old revision. The expected result is not merely a green upload button. Account A can read its current revision, account B cannot read A's key, the retry does not attach an unverified object, and the old revision follows the documented retention rule. Record the request IDs and stage timings, because a 403 during the cross-tenant check and a 408 during upload investigation require different owners.

Retries need an identity model. Generate a revision ID before the first attempt so a retry can address the same intended object, or make each attempt a new revision and clean up abandoned objects later. Never retry a write blindly after a client timeout and assume the object was absent; the timeout describes what the client observed, not what storage committed. HTTP 429 should trigger bounded backoff, while an authorization failure should fail closed and request a new capability through the authenticated service.

Rollback is easier when the old avatar remains readable until the new one is verified. Store the prior key, switch the account pointer atomically, and schedule old revisions for deletion only after a retention window. The window should match support needs and the privacy policy.

Keep the last known-good avatar.

For a first deployment, I would rather carry a short-lived abandoned-object cleanup job than make a failed image update erase the last known-good avatar.

The trade-offs that should change the design

This pattern is not suitable when the product needs public, cache-heavy image distribution, immutable retention, server-side transformations before acceptance, or a storage policy that cannot express tenant-scoped authorization. Choose a service with those controls, or keep the application proxy as the deliberate boundary. Stick with direct upload when the browser policy is testable, the objects are small, and the team is prepared to own CORS diagnostics in production.

Cache policy deserves its own review. A private avatar served through a temporary read capability may still be cached according to response directives, and changing an object key per revision is often easier to reason about than trying to invalidate every intermediary. The right Cache-Control value depends on how the application serves the private image and how quickly revocation must take effect. I'm not sure a single duration can be chosen responsibly without that serving architecture and its SLO.

The cost argument is secondary. Direct upload can reduce application bandwidth, but it moves responsibility to browser compatibility, capability scope, and storage cleanup. A platform team's roadmap should price the on-call surface and the failure investigation time, not just the bytes on an invoice.

References