Browser Document Uploads to Object Storage in 2026 (Presigned vs Server-Mediated)
Tenant isolation changes the answer: a browser upload is safe only when authorization is translated into a narrow object key, content constraint, and short-lived capability. Short answer: use a presigned upload for ordin
Tenant isolation changes the answer: a browser upload is safe only when authorization is translated into a narrow object key, content constraint, and short-lived capability. Short answer: use a presigned upload for ordinary user documents, keep the application server in the authorization and metadata path, and switch to a server-mediated upload when bytes must be inspected or transformed before storage.
This matters in a customer-support system because an attachment is rarely just a blob. It belongs to one tenant, may be included in a per-tenant backup, and must still resolve when an operator restores a selected snapshot. A successful 200 upload to the wrong tenant prefix is therefore an incident, not a win.
Keep the invariant boring.
The browser is untrusted input.
How should a browser upload user documents directly to compatible object storage?
The clean presigned flow has four steps. The browser first asks the application for permission, sending document metadata rather than bytes. The application authenticates the user, derives the tenant from the authenticated principal rather than accepting it as authority from the request body, allocates an opaque object key, and returns a short-lived signed upload capability. After the browser uploads directly, it asks the application to finalize the document; the application verifies the stored object's expected properties and commits the metadata record that backup and restore will later consume.
That finalization step is easy to omit and expensive to rediscover. Without it, a database row may claim an upload exists before the object arrives, or an object may exist without a row and silently escape the next snapshot manifest. Treat "uploaded" and "committed" as distinct states, make finalization idempotent, and expire abandoned intents. The storage key should be application-generated, such as tenants/<internal-tenant-id>/documents/<random-id>, but the tenant prefix alone is not an authorization boundary; the signing service is the boundary.
CORS is a browser permission layer, not tenant authorization. Configure only the application origins, methods, and request headers the upload actually needs, then test the preflight as part of deployment. The browser must send the same signed headers and content type that the server used when producing the capability. A mismatch can yield 403; an application or proxy body limit can yield 413 on the server-mediated path. Those codes are useful test cases because they distinguish policy failure from capacity failure.
The server-mediated alternative is simpler to reason about at first: the browser sends a multipart request to the application, which authorizes, reads, inspects, and writes the object. Yet the byte path now consumes application connections, memory or temporary disk, network egress, and request time. Under bursty support traffic, I would capacity-plan that path as a data plane, with explicit maximum body size, bounded concurrency, streaming backpressure, and an SLO for accepted uploads rather than assuming the ordinary API fleet can absorb it.
The incident lesson is an authorization invariant
Consider a bounded failure: tenant A submits a client-controlled key containing tenant B's prefix, the upload itself succeeds, and a later backup job walks prefixes exactly as designed. The restore code then faithfully reconstructs the wrong ownership relationship. No storage outage occurred. Every component did what it was asked to do, which is why this class of incident deserves more attention than a transient failed request. The support agent may not notice until a ticket is reopened weeks later, when a restored attachment appears under the wrong account and the audit trail points to a perfectly valid object operation. By then, deleting the object is not enough: the snapshot manifest, metadata row, cache, and export pipeline all need a consistent correction, and each extra copy expands the incident's recovery surface.
My first design question is therefore not "can the browser reach the bucket?" It is "which component is allowed to choose the tenant, key, and durable metadata?" The application must own all three. The browser may propose a filename and media type for display, but neither belongs in the authoritative key without normalization and validation. A download should repeat authorization against metadata; possession of a guessed object name must confer nothing.
Backups make the invariant testable. A snapshot manifest should identify the tenant, a snapshot ID, and immutable object versions or checksums where the selected storage design exposes them. Restoration should build a fresh candidate namespace, verify the manifest, and change the active pointer only after validation. Don't overwrite the live tenant prefix object by object: a partial restore creates a mixed point in time and makes rollback ambiguous.
The exact consistency model and versioning controls vary by service and configuration, so I'm not sure a paper review can settle restore behavior for a particular account. A destructive restore drill against an isolated test tenant will. Set a recovery point objective that accounts for metadata and object snapshots together, then measure recovery time with enough documents to expose listing, download, and checksum bottlenecks.
A preventative Go signing boundary
The useful code is not a vendor SDK call. It is the policy immediately before that call, because this is where an authenticated tenant becomes an object key and where upload limits stop being hopeful documentation.
package upload
import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
"time"
)
type Principal struct {
TenantID string
CanUpload bool
}
type Request struct {
Filename string
ContentType string
Size int64
}
type Capability struct {
URL string
ObjectKey string
ExpiresAt time.Time
}
type Signer interface {
SignPut(ctx context.Context, key, contentType string, maxBytes int64, expires time.Time) (string, error)
}
type Service struct {
Signer Signer
Now func() time.Time
NewID func() string
MaxBytes int64
}
func (s Service) Authorize(ctx context.Context, p Principal, r Request) (Capability, error) {
if !p.CanUpload || p.TenantID == "" {
return Capability{}, errors.New("upload forbidden")
}
if r.Size <= 0 || r.Size > s.MaxBytes {
return Capability{}, errors.New("invalid document size")
}
if !allowedType(r.ContentType) {
return Capability{}, errors.New("unsupported content type")
}
ext := strings.ToLower(filepath.Ext(filepath.Base(r.Filename)))
key := fmt.Sprintf("tenants/%s/documents/%s%s", p.TenantID, s.NewID(), ext)
expires := s.Now().Add(10 * time.Minute)
url, err := s.Signer.SignPut(ctx, key, r.ContentType, s.MaxBytes, expires)
if err != nil {
return Capability{}, fmt.Errorf("sign upload: %w", err)
}
return Capability{URL: url, ObjectKey: key, ExpiresAt: expires}, nil
}
func allowedType(v string) bool {
switch v {
case "application/pdf", "image/jpeg", "image/png":
return true
default:
return false
}
}
The ten-minute expiry is an example policy, not a universal optimum. Tune it from observed client upload durations and clock skew, then keep it as short as the user experience permits. Also remember that a declared media type is a claim; if malware scanning, content sniffing, redaction, or legal hold classification must happen before any byte reaches durable storage, this direct path is not suitable. Use the server path, or upload to a quarantined namespace that no consumer can read until an asynchronous scanner promotes it.
Buy versus build, without pretending products are identical
S3 compatibility can reduce adapter work, but it doesn't erase operational differences in identity, CORS configuration, lifecycle controls, consistency guarantees, observability, or egress. Amazon S3, Cloudflare R2, and Google Cloud Storage are reasonable products to include in a proof of concept; none should be selected from an API label alone. Validate the exact signed-request behavior and account configuration you will deploy.
| Decision | Managed object storage | Self-hosted compatible storage |
|---|---|---|
| On-call surface | Provider operates the storage fleet; the team still owns policy, keys, and restore drills | Team owns disks, replication, upgrades, capacity, and failure recovery |
| Tenant isolation | Usually one bucket with application-enforced prefixes, or separate buckets where scale and policy justify them | Same logical choices, plus responsibility for identity integration and control-plane availability |
| Lock-in | Signed-request and object operations may be portable; identity, eventing, lifecycle, and observability often need adapters | Greater control over placement and interfaces; operational procedures become part of the platform |
| Capacity planning | Plan request rates, object counts, quotas, and data movement | Plan all of those plus headroom, rebuild traffic, metadata performance, and hardware failure domains |
| Cost model | Usage and data-movement dimensions require workload modeling | Infrastructure, engineering time, and on-call load require workload modeling |
The catch is organizational. A managed service is not suitable when policy requires storage under infrastructure the provider cannot offer, while self-hosting is a poor choice when the team cannot staff upgrades, capacity forecasting, and restore testing. Stick with server-mediated uploads when synchronous inspection is mandatory or client networks cannot reliably reach the storage endpoint; use direct presigned uploads when application bandwidth is the constraint and the authorization service can issue tightly scoped capabilities.
Separate buckets per tenant can strengthen administrative separation and simplify some deletion policies, but bucket quotas, provisioning latency, and policy management may dominate at high tenant counts. Shared buckets with opaque tenant prefixes reduce control-plane churn but put more weight on application authorization. Neither topology cancels the need for restore tests.
Operate the whole document lifecycle
An upload SLO needs more than HTTP success. Measure authorization failures, signing latency, preflight failures from browser telemetry, upload completion, finalization lag, abandoned intents, scanner backlog if quarantine is used, and snapshot age. Alert on symptoms tied to user harm: a growing gap between created intents and committed documents, or a tenant snapshot older than the recovery point objective, is more actionable than raw bucket request volume.
Deployment tests should exercise one allowed origin and one denied origin, an oversized object, a changed content type, an expired capability, repeated finalization, a user moved out of the tenant, and restore of a selected snapshot into an isolated namespace. Run the same contract suite against any storage adapter under consideration. This catches compatibility gaps before they become assumptions embedded in the UI.
Then test deletion.
A customer-support attachment may be referenced by tickets, audit records, exports, and backup manifests. Define whether deletion is immediate, soft, or delayed by retention policy; make garbage collection traceable; and ensure a restored snapshot cannot accidentally resurrect data whose deletion must remain authoritative. This is where a document-upload feature becomes a records system, with consequences for capacity and recovery that the initial browser demo never showed.
The decision rule is stable: preserve tenant ownership in application metadata, let a narrow capability move ordinary bytes, and route exceptional bytes through controlled inspection. Everything else -- provider choice, bucket topology, and signing implementation -- should survive a restore drill and an on-call review before it earns production traffic.
Sources
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.