Short answer: use a short-lived presigned upload for the browser, but treat it as one step in an avatar ingestion workflow, not as proof that an avatar is safe or even usable. Keep the bucket private, authorize the object key on the server, verify the completed object, and publish only an application-owned avatar reference.
The easiest setup is the one with the fewest hidden authorities. A React client should ask a Next.js server route for permission to upload one named object. The server should decide the key, content constraints, and expiry. The browser then sends the bytes directly to object storage and tells the application that the upload finished. That separation keeps file data off the application path without handing the browser a credential that can enumerate a bucket.
This is an architecture decision record, so the important question is not whether a PUT request is easy to demonstrate. It is what each component is allowed to believe.
Decision: separate permission, bytes, and publication
The invariants are small and worth writing down:
- The browser never receives a long-lived storage credential.
- The bucket remains private; an avatar is read through an authorized application response or a short-lived read URL.
- A user can write only to a server-generated key under that user's namespace.
- An upload is untrusted until the server checks its metadata and content policy.
- The database points to an object key, not to a permanent public URL.
The failure boundary follows those invariants. The presigning endpoint owns authorization. Object storage owns byte acceptance. The completion endpoint owns the transition from “candidate object” to “current avatar.” A failed browser request must not silently become a published avatar, and a successful object write must not be mistaken for a successful database update.
Here is the comparison I would put in the record before implementation:
| Path | What it simplifies | What it makes harder | Appropriate boundary |
|---|---|---|---|
| Browser to private object storage with a presigned request | Application bandwidth and upload latency | CORS, completion state, and content verification | Normal avatars and other bounded files |
| Browser to Next.js, then server to object storage | One origin and one validation point | Server bandwidth, timeouts, and memory pressure | Very small files or strict network controls |
| Browser to storage with a permanent public object URL | A quick display path | Revocation, access control, and accidental disclosure | Non-sensitive public media only |
The rejected option is a public avatar bucket. It is attractive for a demo because an <img> tag needs no authorization. It is the wrong default for user profile data: deletion becomes a cache problem, guessed keys become a disclosure problem, and a later privacy requirement becomes a migration. A public bucket still has a valid use case for genuinely public, immutable assets. Avatars in an account system usually are not that case.
How should React and Next.js split a private bucket upload?
The browser flow should be boring:
- React sends the selected file's name, declared media type, and size to a server endpoint.
- Next.js authenticates the user, ignores any client-supplied destination, and creates a random object key.
- The server returns a presigned upload request with a short expiry and the constraints the browser must use.
- React sends the file directly to object storage, including the signed headers.
- React calls a completion endpoint. The server checks that the object exists, belongs to the requesting user, and meets the accepted policy before changing the avatar record.
The completion call is not ceremony. It closes the gap between an object write and an application state change, which is where abandoned uploads, retries, and “the UI said success” bugs tend to accumulate.
The critical path in Python
The following is deliberately a service-shaped example rather than a provider SDK tutorial. storage.sign_put and storage.head represent the adapter owned by the application; their contract should be tested against the object-storage implementation separately. The important part is that the server chooses the key and persists a pending record before returning permission.
from dataclasses import dataclass
from uuid import uuid4
ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp"}
MAX_AVATAR_BYTES = 5 * 1024 * 1024
@dataclass(frozen=True)
class UploadTicket:
upload_url: str
object_key: str
required_headers: dict[str, str]
def create_avatar_ticket(user_id: str, declared_type: str, size: int) -> UploadTicket:
if declared_type not in ALLOWED_TYPES:
raise ValueError("unsupported media type")
if size <= 0 or size > MAX_AVATAR_BYTES:
raise ValueError("invalid avatar size")
object_key = f"avatars/{user_id}/{uuid4().hex}"
uploads.insert_pending(user_id=user_id, object_key=object_key)
headers = {"Content-Type": declared_type}
url = storage.sign_put(
key=object_key,
expires_in_seconds=300,
required_headers=headers,
)
return UploadTicket(url, object_key, headers)
def complete_avatar_upload(user_id: str, object_key: str) -> None:
pending = uploads.get_pending(user_id=user_id, object_key=object_key)
if pending is None:
raise PermissionError("unknown upload")
metadata = storage.head(object_key)
if metadata.size <= 0 or metadata.size > MAX_AVATAR_BYTES:
raise ValueError("stored object is outside policy")
if metadata.content_type not in ALLOWED_TYPES:
raise ValueError("stored object type is outside policy")
uploads.publish_avatar(user_id=user_id, object_key=object_key)
The sample validates declared metadata twice for a reason: a browser header is an input, not an inspection result. The real pipeline should inspect the object according to its threat model, strip metadata where appropriate, and consider image decoding or transformation before publication. OWASP's upload guidance also calls out filename handling, allowed types, size limits, storage location, and authorization; a presigned URL removes none of those responsibilities.
There is a less visible race here. Two tabs can complete two valid uploads. Use a transaction or an explicit version so the last accepted avatar is intentional, and delete or expire the losing candidate. A retry of the completion request should be idempotent: publishing an already-published key should return the same application state, not create another record.
Failure modes that deserve tests
Start with the cases that look like ordinary success in a browser:
- CORS allows the exact application origin, upload method, and signed headers. A wildcard origin is a poor fit when credentials or sensitive reads enter the design.
- A presigned request expires during a slow upload. The client should request a new ticket and use a new key, rather than retrying an old signature indefinitely.
- The upload succeeds but the completion request is lost. A pending-object reaper can remove candidates after a retention window, while a reconciliation job can find objects with no matching record.
- The user changes accounts between ticket creation and completion. The completion endpoint must bind the key to the authenticated identity, not trust a hidden form field.
- The object has an image extension but is not an acceptable image. Extension checks and client MIME declarations are insufficient by themselves.
- A cached read outlives a permission decision. Responses for private avatar reads need an explicit cache policy; MDN documents how
Cache-Controldirectives control storage and reuse by caches.
One short test is useful: can an authenticated user submit a valid key belonging to another user and publish it? The expected answer is no. That test should exist before the upload widget does.
What does “easiest” leave out?
The direct path is easier on the server, not automatically easier for the system. You still own CORS configuration, expiry behavior, orphan cleanup, image validation, observability, and the user-visible state machine. A progress bar reports bytes sent; it does not report that the application has accepted the avatar.
The catch is that direct browser upload is not suitable when every byte must pass through a controlled inspection network, when the storage service cannot be configured with the required cross-origin policy, or when files are so small that a server proxy removes more operational complexity than it adds. In those cases, stick with the Next.js proxy and accept its bandwidth and timeout costs. Your mileage may vary with corporate proxies and mobile networks, so measure the path your users actually take.
I would also avoid putting a permanent URL in the user table. Store the object key and derive a read response at request time. That gives the application a place to enforce authorization, set Cache-Control, rotate keys, and replace an avatar without pretending that old cached bytes disappeared instantly.
Top comments (0)