Short answer: for a gaming app moving large media, let the browser send bytes to private object storage with a short-lived presigned PUT, and make Node.js or Express sign and confirm the handoff. Keep signed download URLs on the read path. The decision is about access control versus delivery simplicity: the API should authorize the object, while storage carries the file.

That boundary is a capacity decision. A proxy makes every slow player connection consume an API worker, socket, and egress path. A direct upload makes the control plane handle short requests while the storage service handles the long transfer. I put separate SLOs on those paths: signing and confirmation latency belong to the API SLO; upload completion and download time belong to the data path. They fail differently.

Keep the bucket private. No public shortcut.

What should a Node.js Express signer and React client agree on for a private bucket upload?

Treat the upload as a four-step state transition: authorize, reserve, transfer, confirm. The authenticated API decides the object key and allowed content type. It records a pending row, returns a presigned PUT URL and the exact headers to send, and never accepts a client-selected key as authority. React then uploads the file directly. Afterward, it calls the API to confirm the expected key and size.

The key should include a server-generated identifier and the authenticated account or match, such as matches/7f2.../replay.bin; the shortened example is only a naming shape, not a protocol requirement. A player can name a file final-final-2.zip, but that name must not decide which object gets overwritten. The database row is where ownership lives. Object listing is not an authorization system.

Here is the control-plane shape in Go. It deliberately leaves Signer behind an interface: storage vendors differ in SDKs and request details, but the authorization contract does not. A Node.js/Express implementation should expose the same two application endpoints, even if its internal signing library is different.

package main import ( "context" "encoding/json" "errors" "net/http" "time" ) type Signer interface { PresignPut(ctx context.Context, key, contentType string, ttl time.Duration) (url string, headers map[string]string, err error) PresignGet(ctx context.Context, key string, ttl time.Duration) (url string, err error) } type UploadRequest struct { ContentType string `json:"content_type"` Size int64 `json:"size"` } type UploadReply struct { Key string `json:"key"` PutURL string `json:"put_url"` Headers map[string]string `json:"headers"` } func createUpload(signer Signer, userID string, w http.ResponseWriter, r *http.Request) { var in UploadRequest if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.Size <= 0 || in.ContentType == "" { http.Error(w, "invalid upload request", http.StatusBadRequest) return } key := serverKey(userID, in.ContentType) // Never use a browser-supplied object key. // The row and key allocation should be idempotent for a client request ID. if err := reservePendingUpload(r.Context(), userID, key, in.Size, in.ContentType); err != nil { http.Error(w, "could not reserve upload", http.StatusConflict) return } url, headers, err := signer.PresignPut(r.Context(), key, in.ContentType, 10*time.Minute) if err != nil { http.Error(w, "could not create upload handoff", http.StatusServiceUnavailable) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(UploadReply{Key: key, PutURL: url, Headers: headers}) } func serverKey(userID, contentType string) string { return "replace-with-validated-key" } func reservePendingUpload(context.Context, string, string, int64, string) error { return nil } var _ = errors.New 

The placeholder functions are application-owned seams, not storage routes. In production, validate the declared size and type, enforce a per-user quota before signing, and bind confirmation to the reserved key. A 200 from the signing endpoint proves only that authorization was issued. It does not prove that a 4 GB replay reached storage.

Implement the reservation and confirmation boundary

The browser must replay the signed method and headers exactly. If the signer binds Content-Type, changing it in React changes the request being authorized. Do not log the full presigned URL; its query string is a bearer credential. A ten-minute lifetime is a starting policy for a game replay or build artifact, not a universal number, and your mileage may vary with file size and player networks.

Confirmation is a second authorization check, not a courtesy callback. The server should verify that the caller owns the pending row, that the object key is the reserved key, and that storage reports the expected size and content type. Mark the row complete only then. If confirmation is repeated, return the same completed result rather than creating a second object association. If the upload expires, leave the row pending for reconciliation and let a bounded cleanup policy remove abandoned objects. I would also retain the original reservation request ID, declared size, observed size, content type, and timestamps, because a later support ticket needs to distinguish a user who never started the PUT from a browser that sent bytes but never reached the confirmation endpoint. That record is small compared with the media itself, and it gives the on-call engineer a useful answer without searching object listings or asking the player to retry blindly. The failure path matters here: a signed URL can be valid, the transfer can finish, and the application can still be missing its durable association if confirmation is treated as optional.

That's the handoff.

The most useful alert is not “PUT failed.” Track the counts of reserved, confirmed, expired, and rejected objects, split by origin and content type. A growing reserved-to-confirmed gap points at browser connectivity, CORS, expiration, or a client state bug; a slow signing endpoint points at the API. Those are separate investigations. I don't want one blended “upload success” metric hiding both.

Test CORS, expiration, and signed downloads as one release gate

The storage origin must answer the browser's CORS preflight for the React origin, the PUT method, and the headers used by the signed request. MDN describes CORS as a browser-enforced mechanism based on response headers; an Express CORS middleware cannot authorize a request that the browser sends to a different storage origin. Test the exact production origin, not only localhost, and include credential behavior only if the design actually uses browser credentials.

Downloads use a different decision. Keep the bucket private and mint a short-lived signed GET only after the application authorizes the user or match. Return the URL to the browser or a media client without storing it as a permanent document URL. A signed URL is delivery plumbing, not a permission record; revocation requires a policy such as deleting the object, changing the database authorization, or choosing storage controls that support the needed revocation model.

This design is not suitable when a public CDN URL is the product requirement, when clients need arbitrary byte-range transformation at the API edge, or when the team cannot operate bucket CORS and lifecycle configuration. Stick with a proxy or a managed upload gateway when central inspection and immediate revocation are worth paying the bandwidth and connection cost. The catch is that delivery simplicity and control do not come for free on the same path.

Retain, retry, and roll back without changing access policy

Set the object lifecycle policy alongside the application deployment. Abandoned uploads, temporary transcodes, and old replay files need different retention classes; a single deletion rule is usually a policy accident. Object lifecycle management is a storage concern, and the AWS lifecycle documentation is a useful reference for the shape of that policy even when the underlying provider differs.

Retry the control-plane calls with bounded backoff and an idempotency key. Do not blindly retry a browser PUT after an uncertain disconnect unless the storage contract and object key make that retry safe. A retry can be a duplicate write or an overwrite, depending on the provider's semantics. The API should be able to answer “which key did I reserve?” without allocating another one.

Before rollout, exercise four paths from each supported React origin: a valid PUT, a denied origin, an expired PUT, and a signed download after authorization is removed. Verify that a successful transfer still requires confirmation. Record signing latency separately from transfer duration, and put pending-object age on the dashboard.

Rollback means stop issuing new upload handoffs, preserve private objects, and continue serving already-confirmed media through the previous read path. Do not make the bucket public during an incident. That emergency change erases the access-control boundary while the team is least able to audit it.

References

Sources