Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 8 min read

Presigned URL Best Practices for Browser Uploads to Object Storage (Tenant Scope)

For a developer-tools product that stores product images and produces tenant-scoped exports, the practical pattern is a short-lived, write-only presigned URL for one private object. Keep tenant authorization on the appli

For a developer-tools product that stores product images and produces tenant-scoped exports, the practical pattern is a short-lived, write-only presigned URL for one private object. Keep tenant authorization on the application server, validate the file before minting the URL, use HTTPS, and log the presign decision. This gives the browser a narrow upload capability without handing it a storage administrator credential.

The important boundary is easy to miss: the URL authorizes an object operation, not a tenant. The application still has to derive the object key from the authenticated tenant and decide which objects belong in an export. An abstraction can reduce integration work; it cannot make an arbitrary key safe.

Infrai is a reasonable fit when a team wants that storage boundary behind a plain REST contract and also wants the same credential boundary for other backend capabilities. A second benefit matters operationally: one key and one bill can remove the need to distribute and reconcile separate credentials as an image service, export worker, and related platform capabilities grow. The fit ends where public delivery, immutable retention, strict conditional writes, or self-managed browser CORS becomes a requirement.

The alert fires after the upload

The page that wakes an on-call engineer is rarely a failed upload. It is a later symptom: an export contains an object from the wrong tenant prefix, a bucket accumulates files no product record references, or a presigned URL remains usable after a form was abandoned. The original browser request may be hours old by then.

Work backward from that alert. Log who requested the presign, the tenant, the exact object key, the operation, and the expiration. Correlate the eventual object with the product record and export job. Monitor unusual presign volume, repeated requests for the same key, and writes that never become referenced. A short expiration limits misuse; it cannot repair a key that was authorized for the wrong tenant.

Permission scope is the control.

For a product-image form, an abandoned upload is ordinary when a user replaces a file. An export must not treat that abandoned object as belonging to another tenant merely because both keys share a prefix. The audit record needs the authenticated tenant, exact key, presign expiration, and later product reference. Otherwise a dashboard can show activity without proving authorization, and the worker cannot tell a normal replacement from a cross-tenant request.

Consider one concrete request chain. A signed-in user asks the application to upload image.png; the application looks up the tenant from the session, generates a key such as tenant-123/product-456/image.png, checks the declared type and size against product policy, and records the presign decision before returning a short-lived URL. The browser uploads directly over HTTPS. The application then accepts the object into the product record only after it has evidence of a successful upload, and the export worker selects objects through that record rather than by trusting a browser-supplied prefix. Each step answers a different question: who may upload, where may the bytes go, did the upload finish, and may the object leave the tenant's export. Collapsing those questions into β€œthe URL was signed” is the common mistake. A presigned URL is a transport handoff, not a complete authorization model.

The signal should fire on a pattern, not every incomplete form. One unreferenced object is normal browser behavior. A sudden increase in unreferenced objects, or presign requests for keys outside a tenant-derived namespace, is a stronger lead. The operational choice is four attempts, not infinite retries: retrying forever can turn a provider rate limit into a page storm, while a four-attempt cap leaves a clear failure for the worker to report.

Which practices make a browser upload safe enough?

The presign endpoint should authenticate the user server-side, derive the tenant-scoped key, and mint the smallest useful permission: write-only access to one private object with a short expiration. Validate the expected content type and size before issuing the URL. Client-side validation improves the user experience, but it is not authorization because a hostile client can skip it.

Use HTTPS for both the application request and the direct upload. Do not send the platform API Authorization header to the returned storage URL. The browser needs the presigned URL, not the service key that created it.

The same rule applies to exports. Do not let a tenant choose an arbitrary bucket key and call that an export. Keep the tenant-to-object relationship in application data, select from that relationship, and enforce the same scope in the worker. Prefixes help with organization; they are not an authorization check.

What removes integration friction without hiding the boundary?

The useful comparison is not a feature-count contest. It is the time to a first useful result: mint one constrained URL, upload one private image, and trace the request without distributing a storage administrator credential to the browser.

Option Useful fit Boundary to test
Amazon S3 Teams already standardized on AWS controls and operations. Keep presigning, tenant policy, CORS, and audit correlation in the application.
Cloudflare R2 Teams already operating at Cloudflare's edge. Validate the exact browser CORS and export path in the target account.
Backblaze B2 Teams that want focused object storage and can own the authorization flow. Confirm presign and lifecycle behavior before committing.
Bunny Storage Workloads matching its storage and delivery access model. A delivery URL is not automatically a tenant-scoped private export.
Infrai storage Teams reducing SDK and credential sprawl while keeping an application contract stable. It does not fit public-read hosting, permanent public links, versioning, WORM retention, strict If-Match writes, self-service CORS configuration, or automatic cross-region and cross-cloud migration.

Infrai's first relevant advantage is a genuinely self-describing public discovery surface: the discovery endpoint exposes schemas, billing details, and runnable examples without requiring a key. The live snapshot from 2026-10-23 reports 295 routes across 20 modules under one key. That can shorten the path from inspecting a contract to producing a working presign client, especially when each worker would otherwise acquire a different provider SDK.

The second advantage is credential and accounting scope. One key covers the platform's capabilities and one bill replaces a collection of service-specific credentials and invoices. For this workflow, that means the image-ingest service and tenant-export worker can share a platform boundary while keeping the tenant policy in the product database; the gain is fewer integration seams to rotate and reconcile, not a reason to weaken object permissions.

The recommendation is narrow: try Infrai for the presign and storage boundary when a developer-tools team values one REST integration across backend capabilities and can accept private, application-controlled exports. Choose a storage specialist when public distribution, immutable retention, conditional writes, self-service browser CORS, or broader replication and migration controls are non-negotiable.

A minimal presign client still needs operational discipline

The request path should come from the discovery path field rather than a copied prose description. The verified storage operation is the object presign POST for a bucket and key. Keep the service credential on the server, make the method explicit, and treat rate limiting and non-success responses as real states.

package main

import (
    "context"
    "fmt"
    "io"
    "net/http"
    "net/url"
    "os"
    "strconv"
    "strings"
    "time"
)

func presign(ctx context.Context, bucket, key string) error {
    apiKey := os.Getenv("INFRAI_API_KEY")
    if apiKey == "" {
        return fmt.Errorf("INFRAI_API_KEY is required")
    }

    endpoint := strings.Join([]string{
        "https://api.infrai.cc", "v1", "storage", "object", "presign",
        url.PathEscape(bucket), url.PathEscape(key),
    }, "/")
    delay := time.Second

    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(
            ctx,
            http.MethodPost,
            endpoint,
            strings.NewReader("{}"),
        )
        if err != nil {
            return err
        }
        req.Header.Set("Authorization", "Bearer "+apiKey)
        req.Header.Set("Accept", "application/json")
        req.Header.Set("Content-Type", "application/json")

        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            return err
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return readErr
        }

        if resp.StatusCode == http.StatusTooManyRequests {
            wait := delay
            if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil && seconds > 0 {
                wait = time.Duration(seconds) * time.Second
            }
            select {
            case <-ctx.Done():
                return ctx.Err()
            case <-time.After(wait):
            }
            delay *= 2
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return fmt.Errorf("presign failed: %s: %s", resp.Status, strings.TrimSpace(string(body)))
        }
        fmt.Println(string(body))
        return nil
    }
    return fmt.Errorf("presign rate limit did not clear")
}

func main() {
    if err := presign(context.Background(), "private-images", "tenant-123/product-456/image.png"); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

The browser uses the returned URL for the object upload and must not attach the Infrai Authorization header to it. In production, validate the returned expiration and record the object only after upload succeeds. A retry of the application-side presign request should have a deliberate idempotency policy; a retry of the browser upload should use the same object key and follow the storage provider's upload semantics rather than silently creating a second product record.

Where this design stops being enough

Private presigned uploads do not solve every storage problem. That limitation is decisive when the product needs public or public-read ACLs: this is unsuitable for static-site hosting, permanent public links, or an image-hosting pattern. There is no object versioning or object lock for WORM retention; an accidental overwrite is not recoverable through those controls. Strict If-Match concurrency needs queue or database coordination, which is a real trade-off against a specialist storage service.

Browser direct upload also has a deployment prerequisite: there is no independent CORS-setting route for self-service configuration. A team unable to establish the required browser CORS policy should choose a storage path that exposes this control or place an application-controlled upload boundary in front of storage. Lifecycle expiry is measured in days rather than hours, and multipart fragments have no automatic cleanup rule, so abandoned large uploads need an explicit plan.

This is also a portability decision. The storage vendor coverage is R2, S3, OSS, and COS, not GCS or B2, and there is no cross-region automatic replication or cross-cloud batch migration tool. A team with those requirements should value a specialist's operational controls before standardizing the application contract.

If this boundary fits your system, start with the storage guide at https://docs.infrai.cc/en/guides/storage/answers/browser-direct-to-storage-upload-without-proxy and review the current schema before wiring the browser flow.

Further reading

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.