Presigned File Upload for SaaS: URL Delivery or Relaying Through the Server?
The constraint that changes this decision is not file size; it is which system must absorb every uploaded byte during a traffic spike. Short answer: use a short-lived signed URL for direct browser uploads of ordinary private documents, while keeping authorization and metadata creation on the application server; reserve a server proxy for uploads that require synchronous inspection, transformation, or network isolation. That choice keeps the document private at rest without making the web tier the data plane. CORS is part of the browser permission boundary, not an authorization system, and the signed URL should grant one narrow operation on one generated object key. The upload is not complete merely because a client received a successful storage response. Treat it as complete only after the application verifies the expected object and commits the corresponding document record. This is a control-plane/data-plane split. It is also an on-call decision. What failure signal should drive the upload-path decision? A proxy looks comfortable because every byte passes through code the team already operates. The catch is capacity multiplication: an upload consumes inbound bandwidth, outbound bandwidth, a request slot, and often temporary buffers on the application fleet. If the arrival rate is R uploads per second and mean object size is S bytes, the proxy data path must move roughly 2 × R × S bytes per second across its network interfaces before retry overhead, TLS work, or headroom. A direct path removes the application fleet from that byte transfer, although the application still authorizes the request and records its state. Capacity is only half the argument. Define the upload SLO around the user-visible transaction: an authorized user can start an upload, transfer the bytes, and see the document become available within the promised latency. Then assign separate indicators to URL-issuance success, storage transfer success, verification latency, and abandoned-upload age. A single “upload endpoint availability” graph hides the exact distinction needed during an incident. Retries make the difference sharper. A browser retrying a signed PUT to the same generated key can be made idempotent at the workflow level. A proxy retry can also be safe, but it sends the bytes through the application again and needs an explicit policy for partial bodies, client disconnects, and duplicate metadata writes. Don't let a generic HTTP retry policy silently decide document identity. Assume retries. Use a proxy when the application must inspect the byte stream before any object may enter storage, when policy requires the storage service to be unreachable from user networks, or when files are tiny and upload volume is low enough that a separate direct-upload state machine would add more operational weight than it removes. Direct upload is not suitable when acceptance depends on a synchronous transform performed only inside the application boundary. In that case, keep the proxy and budget it like a data service, not like an ordinary JSON endpoint. I'm not sure any universal file-size cutoff is defensible. The threshold depends on concurrency, regional bandwidth, runtime buffering, inspection cost, and the failure budget the team is willing to spend; a load test with the real object-size distribution resolves that uncertainty better than a fashionable number. How should a browser upload private SaaS documents with CORS and a signed URL? The application should generate an opaque object key, authorize the user against the intended document container, create a pending upload record, and issue a narrowly scoped signed URL. The browser then sends the file directly to storage using the exact method and headers covered by the signature. Afterward, the browser asks the application to finalize the upload; the application verifies the object before changing the record from pending to available. Keep the bucket private. A signed URL is temporary authority attached to a request, not a reason to make the object publicly readable. The key should come from the server rather than from a user-supplied filename — filenames collide, leak context, and are awkward authorization boundaries. Preserve the display name as metadata in the application database instead. CORS should allow only the web origins, methods, and request headers that the browser path actually uses. It does not make an otherwise unauthorized object operation valid. This distinction matters when debugging a 403: a failed browser preflight, an expired signature, a method mismatch, and a header mismatch may look similar in a UI, but they belong to different control points. Record enough client-visible classification to separate them without logging the signed query string. The state transition should be explicit: pending: authorization succeeded and an object key was reserved. transferred: the client reports that storage accepted the bytes, but the server has not verified them. available: server-side verification matched the expected constraints and metadata was committed. rejected or expired: verification failed or the pending deadline elapsed; lifecycle cleanup may remove the object later. No magic. The following Go example keeps signing vendor-neutral by putting it behind a small interface. The handler does not accept a client-selected key, and it returns the method and required headers alongside the URL so the browser does not guess what was signed. package upload import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "net/http" "time" ) type PutGrant struct { URL string `json:"url"` Method string `json:"method"` Headers map[string]string `json:"headers"` ExpiresAt time.Time `json:"expires_at"` } type Signer interface { SignPut(ctx context.Context, key, contentType string, expiresAt time.Time) (PutGrant, error) } type PendingStore interface { Create(ctx context.Context, uploadID, ownerID, objectKey, contentType string, expiresAt time.Time) error } type Handler struct { Signer Signer Pending PendingStore Now func() time.Time } type intentRequest struct { ContentType string `json:"content_type"` } type intentResponse struct { UploadID string `json:"upload_id"` Grant PutGrant `json:"grant"` } func (h Handler) CreateIntent(w http.ResponseWriter, r *http.Request) { ownerID, ok := authorizedOwner(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } var in intentRequest if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&in); err != nil { http.Error(w, "invalid request", http.StatusBadRequest) return } if !allowedDocumentType(in.ContentType) { http.Error(w, "unsupported document type", http.StatusUnsupportedMediaType) return } uploadID, err := randomID() if err != nil { http.Error(w, "could not create upload intent", http.StatusInternalServerError) return } objectKey := "documents/" + uploadID expiresAt := h.Now().Add(10 * time.Minute) if err := h.Pending.Create(r.Context(), uploadID, ownerID, objectKey, in.ContentType, expiresAt); err != nil { http.Error(w, "could not create upload intent", http.StatusInternalServerError) return } grant, err := h.Signer.SignPut(r.Context(), objectKey, in.ContentType, expiresAt) if err != nil { http.Error(w, "could not create upload grant", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(intentResponse{UploadID: uploadID, Grant: grant}) } func randomID() (string, error) { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { return "", err } return hex.EncodeToString(b), nil } The omitted authorizedOwner and content-type policy are application decisions, while the signer is an adapter for the chosen object store. In production, route unexpected internal errors to telemetry with a correlation identifier and return a stable public error body. Don't return a signing URL in logs or traces; possession of that URL conveys its temporary capability. There is a subtle ordering problem in this flow. Creating the pending record before signing can leave a record with no usable grant if signing does not complete; signing before creating the record can mint authority that the application never tracks. Prefer the first order, give pending records a deadline, and make grant re-issuance an authorized operation against the same upload identity. The stale record is visible and cleanable. Untracked authority is harder to reason about. Verification must be authoritative Do not accept the browser's claim about size, media type, checksum, or successful completion as proof. The finalize handler should read object metadata through a server-side storage interface, compare it with the pending record and policy, then commit availability with an idempotent database transition. If two finalize requests race, both should converge on the same document identity. The exact integrity mechanism depends on the storage API and upload mode. Require whatever checksum or conditional operation the selected system can verify end to end, and test the semantics rather than assuming similarly named fields mean the same thing everywhere. The supplied storage documentation should be the authority for provider-specific headers. Your mileage may vary across adapters — especially for multipart uploads — so keep those details out of the browser's business logic. Verification also needs negative-path tests. Exercise an expired grant, an origin outside the CORS allowlist, a different HTTP method, a changed signed header, an oversized object, a duplicate finalize call, and a pending record whose object never arrived. None of those tests needs a public bucket. In staging, run them against the same CORS shape and signing adapter used in production; a mock can validate the state machine, but it cannot prove that a real preflight and signature agree. Observability should follow the upload identity without recording credentials. Useful fields include the opaque upload ID, tenant identifier, state transition, normalized failure class, object-size band, adapter name, and elapsed time. Avoid the raw object key when it embeds tenancy, and strip query strings from storage URLs. Set alerts on burn rate for the end-to-end SLO, then use the component indicators to localize the fault. One long-lived metric is especially useful: age of the oldest pending upload. It catches lost finalize calls, clients that closed their tabs, and cleanup drift without treating every abandoned attempt as an immediate page. Page on user impact. Ticket the garbage. Buy, build, or proxy the data path? The implementation decision belongs in the same review as on-call ownership and lock-in. “Managed storage” does not eliminate application work; it moves the byte plane and supplies primitives. The application still owns authorization, tenant boundaries, workflow state, retention intent, audit context, and recovery behavior. Choice Prefer it when Operational cost you retain Reason to reject it Direct signed upload to managed object storage Browser reachability is allowed and most documents can be verified after transfer Signing adapter, CORS tests, pending-state cleanup, metadata verification, provider semantics Synchronous inspection or an isolated network boundary is mandatory Application server proxy Every byte must cross an application-controlled inspection point before storage Bandwidth capacity, backpressure, buffering, retry safety, longer request handling Upload traffic would compete with the control plane or inflate the on-call surface Self-hosted object storage with signed operations The team has a concrete control, locality, or portability requirement and can operate the service Capacity, upgrades, durability design, monitoring, repair, access policy, signing integration The team cannot staff storage operations to the required SLO Portability is not achieved by wrapping everything in a large “storage abstraction.” Keep the interface narrow around the operations the workflow needs: sign a constrained write, inspect metadata, delete an object, and apply retention or lifecycle intent. Provider-specific configuration still exists, but it stays in an adapter and in infrastructure code. This makes a migration finite without pretending all object stores have identical consistency, checksum, multipart, or policy behavior. Storage cost matters, although request and byte prices alone are a weak selection criterion. Model retained bytes over time, request volume, transfer paths, abandoned-object cleanup, replication policy, and the engineering time attached to alerts and upgrades. Then stress the model with a peak day and a retention-policy mistake. A cheap byte can carry an expensive pager. Roll out with a reversible boundary Ship the direct path behind a tenant or percentage gate while leaving the proxy available as a policy-selected route, not as an automatic retry destination. Automatic fallback can upload the same document twice and hides a broken CORS or signing configuration. The server should choose one path when it creates the intent, record that choice, and keep retries on that path for the lifetime of the upload. Before increasing exposure, verify end-to-end SLO burn, p95 and p99 finalize latency, oldest-pending age, rejection classes, object-size distribution, and web-tier network utilization. Compare those signals with a baseline that includes peak concurrency. Roll back by issuing new intents on the previous path; allow already-issued signed operations to expire naturally, continue accepting valid finalize calls, and let the lifecycle policy remove abandoned objects after the defined safety window. Retention deserves an explicit test because cleanup is destructive. Object lifecycle management can transition or expire objects according to configured rules, so validate rule scope against a non-production prefix, confirm that available documents are excluded from abandoned-upload cleanup, and audit the result before widening the rule. The AWS object lifecycle documentation describes lifecycle configuration concepts, while the Google Cloud Storage documentation is the starting point for its storage-specific behavior. Neither replaces a recovery exercise. The final recommendation stays conditional: use direct signed uploads when post-transfer verification is acceptable and the browser may reach private object storage through a narrow CORS policy; stick with a capacity-planned proxy when inspection or network controls require every byte to traverse the application. In both cases, the durable design is the same: generated keys, explicit workflow state, authoritative verification, measurable SLOs, bounded retries, lifecycle cleanup, and a rollback that does not strand uploads. Sources https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lifecycle-mgmt.html https://cloud.google.com/storage/docs
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to