DEV Community

NyxenL29
NyxenL29

Posted on

Tenant-Isolated Secure Direct Browser Uploads: A Generic Object-Storage Alternative

Short answer: use a short-lived, server-authorized upload to private object storage, then verify the object before recording it as ready. For generic files, this is usually the simplest cost shape; the difficult part is tenant isolation, cleanup, and an SLO that measures verified bytes rather than a successful authorization response.

The operational constraint is ownership. If a platform team accepts private PDFs, ZIP archives, source bundles, and arbitrary user files, it owns the boundary between an authenticated tenant and a storage key. A browser upload library can make the happy path look small, but it cannot make an incorrect key prefix safe. That is why I would decide the isolation model before comparing upload abstractions.

The first failure is an ownership failure

The upload endpoint should grant one narrow capability: this authenticated session may write this object key, with this size limit, until this short expiry. The browser must not receive a long-lived storage credential, and it must not choose the tenant prefix. The application derives both from server-side identity and the upload record.

The object key needs enough structure for policy and cleanup, but it should not become an authorization mechanism by itself. A useful shape is a server-generated tenant identifier, an upload identifier, and a non-guessable filename component. Keep the original display name in the database, where it can be escaped and audited; do not use an untrusted filename as the storage path. The application should derive the prefix from the authenticated session, and the storage policy should make that prefix reviewable, testable, and boring. If the only proof of isolation is a convention in a frontend helper, there is no useful proof at all.

The state machine is more important than the upload button:

  1. Authenticate the user and resolve the tenant on the server.
  2. Create a pending upload record with the allowed size, media policy, key, and expiry.
  3. Return a short-lived signed write capability for exactly that key.
  4. Let the browser transfer the bytes directly to storage.
  5. Verify the resulting object through the normal server-side storage path.
  6. Change the record to ready only after verification; otherwise expire or quarantine it.

No verification, no ready state.

That is the boundary.

That last transition prevents a control-plane success from being mistaken for a data-plane success. It also gives support and on-call engineers something concrete to inspect: authorization issued, transfer observed, verification completed, or pending record aged out. A single upload counter cannot tell those states apart.

I would publish an SLO for authorized attempts reaching verified-ready within a defined window. Track transfer failures, verification misses, pending age, expired records, and deletion completion as separate signals. Capacity planning follows the same model: estimate peak upload starts per second, concurrent bytes, verification calls, retries, abandoned objects, and cleanup work. Average daily bytes are not a capacity plan.

How should secure direct browser uploads handle generic files in object storage?

Compare ownership, not just the per-byte line item. A managed upload layer may reduce application code while adding another policy boundary. Provider-native object storage may give more direct control while making bucket policy, CORS, lifecycle rules, and observability part of your runbook. A self-hosted S3-compatible service can change the infrastructure bill again, but it also moves durability, upgrades, replication, and incident response onto the platform team.

Approach Good fit Poor fit Cost and operational trade-off
Managed upload layer A team that values a ready-made upload workflow and accepts an extra control boundary Workloads needing precise storage policy or unusual retention semantics Less application plumbing, more service-specific dependency
Provider-native object storage Direct browser writes, private objects, and a team willing to own policy and lifecycle configuration A team with no capacity for storage operations Straightforward transfer path, with more configuration and provider coupling
Self-hosted object storage Existing storage operations, predictable locality, and control over the data plane Small teams without durability and on-call capacity Avoids a managed-service margin, but makes failure handling your responsibility
Application-proxied upload Small files where centralized inspection is the dominant requirement Large files or services with tight egress and latency budgets Simple authorization story, but application bandwidth and scaling become the bill

For this scenario, I would start with private object storage and a reviewed signed-write flow when tenant isolation is the primary decision axis. “Cheapest” is only meaningful after counting egress, verification, abandoned objects, support time, and the cost of a cross-tenant exposure. A lower storage rate cannot compensate for an authorization model that is difficult to audit.

The choice changes when the product needs image transformations, permanent public delivery, immutable retention, or a hosted workflow with little storage configuration. Those are different requirements, not missing polish on a generic-file design. Stick with the approach that owns the required guarantee when the team cannot operate the corresponding control plane.

Put the state machine ahead of the SDK

The server should create an upload record before it creates a signed write. That record is the source of truth for tenant, key, expected size, state, expiry, and deletion status. The signed request is a temporary permission derived from it, not a replacement for it. This ordering matters during a partial failure: if signing succeeds and the database write happens afterward, an operator can be left with an object that has no owner record; if the record is pending first, an expiry worker has something it can reconcile, and the service can distinguish an abandoned upload from a user who has not yet finished transferring bytes.

The following Go sketch keeps the storage contract intentionally small. The implementation behind Signer can be backed by a provider SDK or another S3-compatible service; the application logic does not need to expose that choice to the browser.

package upload

import (
    "context"
    "crypto/rand"
    "encoding/hex"
    "errors"
    "fmt"
    "time"
)

type Upload struct {
    ID        string
    TenantID  string
    Key       string
    MaxBytes  int64
    ExpiresAt time.Time
    State     string
}

type Signer interface {
    SignedWrite(ctx context.Context, key string, maxBytes int64, expiresAt time.Time) (string, error)
}

type UploadStore interface {
    CreatePending(ctx context.Context, upload Upload) error
}

func Start(ctx context.Context, tenantID string, maxBytes int64, signer Signer, store UploadStore) (Upload, string, error) {
    if tenantID == "" || maxBytes <= 0 {
        return Upload{}, "", errors.New("invalid upload policy")
    }

    idBytes := make([]byte, 16)
    if _, err := rand.Read(idBytes); err != nil {
        return Upload{}, "", fmt.Errorf("create upload id: %w", err)
    }
    id := hex.EncodeToString(idBytes)
    expiresAt := time.Now().UTC().Add(10 * time.Minute)
    upload := Upload{
        ID:        id,
        TenantID:  tenantID,
        Key:       fmt.Sprintf("tenant/%s/upload/%s", tenantID, id),
        MaxBytes:  maxBytes,
        ExpiresAt: expiresAt,
        State:     "pending",
    }

    if err := store.CreatePending(ctx, upload); err != nil {
        return Upload{}, "", fmt.Errorf("record pending upload: %w", err)
    }
    url, err := signer.SignedWrite(ctx, upload.Key, upload.MaxBytes, upload.ExpiresAt)
    if err != nil {
        return Upload{}, "", fmt.Errorf("sign upload: %w", err)
    }
    return upload, url, nil
}
Enter fullscreen mode Exit fullscreen mode

The sketch does not make the browser trusted. The browser receives a URL and policy for one pending record; it cannot select another tenant or turn a display name into a path. The verification worker should load the pending record, check that the object exists at the recorded key, enforce the recorded size and content rules, and perform an idempotent state transition. Repeated completion notifications must not create repeated ready events.

If a client retries after a dropped connection, tie the retry to the upload ID and reconcile by key. Do not create a second database row merely because the client lost the response. If the policy requires a stronger content inspection step, keep the object pending until that inspection has finished. The exact scanner and retention period are application decisions; the isolation invariant is not.

Treat verification as the production contract

Test the authorization boundary with two tenants before load testing the transfer. A request authenticated as tenant A must never receive a key under tenant B, and a completion request for a different upload ID must not advance the caller’s record. Include expired capabilities, oversized requests, duplicate completion events, missing objects, and a retry after a timeout in the test matrix.

Observe the path as a sequence rather than a single endpoint. Useful dimensions include tenant, upload state, object size band, and reason for rejection, with tenant identifiers protected from becoming high-cardinality metric labels. Logs should carry the upload ID and a redacted key, while access to original filenames and download links should follow the same privacy policy as the uploaded content.

Run a capacity exercise against the actual peak shape. Ten thousand small uploads can stress metadata and verification more than their byte total suggests; a small number of large uploads can exhaust connection, egress, or worker limits. Reserve headroom for retries and for a cleanup sweep after an incident. A service that meets its average throughput target but cannot drain pending records is not meeting the user-visible contract.

Deletion is part of the design. GDPR Article 17 describes the right to erasure, so retain enough ownership information to find the object, revoke application access, delete the object, and record completion. The deletion worker must be safe to retry and should distinguish “database row closed” from “object confirmed absent.” Prefix-based cleanup is useful, but only when the prefix was generated from trusted tenant state. A useful deletion run is deliberately uneventful: load the ownership record, check that its state permits deletion, issue the storage delete, confirm absence through the storage read path, write the erasure result, and emit a metric that lets the next retry see exactly where the previous attempt stopped. Do not collapse those actions into a boolean called deleted, because a timeout after the remote delete has a different recovery path from a database transaction that never committed. Keep pending records until the reconciliation decision is durable, and make the worker tolerate receiving the same request twice.

What does the cost boundary leave out?

The catch is the team boundary. Direct browser upload does not remove storage operations; it moves them into policy, CORS, lifecycle, key management, verification, and cleanup. If the organization cannot staff those controls, a managed workflow may be the better engineering decision even when its transfer path looks less portable.

This pattern is also a poor fit when the required guarantee is public media delivery with transformations, write-once retention, cross-region replication, or a permanent customer-facing URL. Generic private storage should not be stretched into a media pipeline or an immutability system. Choose a service or architecture that states those guarantees explicitly.

Rollback needs an equally explicit boundary. Stop issuing new signed writes for the affected tenant or bucket, keep pending records visible, and prevent the reconciler from deleting objects whose ownership has not been resolved. Migrate in bounded prefixes, count objects before and after, sample content verification, and close deletion records only after storage absence is confirmed. A rollback that copies data while new keys are still being issued is not a rollback; it is two writers competing for the same inventory.

I would trigger review on a sustained breach of the verified-ready SLO, a growing pending-age distribution, or an authorization-policy mismatch. The exact threshold belongs in the service's error budget policy. Your mileage may vary because retention, file sizes, and tenant counts change the failure surface; measure those inputs before choosing a limit.

References

Top comments (0)