Dev.to Security 🔐 Cybersecurity 👁 0 📖 7 min read

How to Choose 3 Object Storage Services for Browser File Uploads with Presign URLs

TL;DR: For private logistics uploads, the least complex design is direct browser-to-object-storage upload followed by a short-lived, object-scoped download URL. Treat the URL as a bearer credential, keep it out of caches

TL;DR: For private logistics uploads, the least complex design is direct browser-to-object-storage upload followed by a short-lived, object-scoped download URL. Treat the URL as a bearer credential, keep it out of caches and logs, bind it to the shipment and user before issuing it, and alert on authorization failures before the download endpoint becomes the page that wakes the on-call. The cheapest service is the one that meets the required retention, egress, request, and operational constraints; a low storage rate does not repair a weak access boundary.

The alert arrived as a download failure rate spike. The on-call saw drivers retrying proof-of-delivery images, while the upload path looked healthy in the dashboard. That page was late: the useful signal was the earlier rise in rejected link requests for one shipment, followed by a growing gap between issued links and completed downloads.

This is the pattern I use for a logistics platform that accepts private photos, signed delivery documents, and occasional short video from a browser. The browser uploads directly, the application authorizes the object, and the delivery URL expires. The decision is access control versus delivery simplicity, with capacity planning and SLOs making the boundary explicit.

Which object storage service fits browser file uploads?

Start with the user-visible failure, then work backward. A useful event record has a request id, tenant or account id, shipment id, object key hash, operation, authorization result, expiry timestamp, response class, and latency bucket. Do not put the bearer URL itself in a log. That single choice limits the blast radius of routine log access.

For the upload SLO, measure the time from the browser receiving an upload authorization to the storage service confirming the object, and separate authorization latency from storage latency. For the download SLO, measure link issuance and the actual first byte separately. A link that is issued quickly but fails at delivery is not a healthy download experience.

The earlier alert should be based on a ratio, not a raw count: rejected download authorizations divided by download authorization attempts, split by tenant and operation. A raw count rings during a busy dispatch shift. A ratio catches a tenant-specific policy problem without pretending that ten failures and ten thousand failures mean the same thing. Thresholds belong to the SLO and traffic shape; they should be tested against a normal peak before paging.

The instrumentation change is small enough to review in one pull request. Keep the decision function pure so policy tests do not need a storage account or a browser.

package access

import "time"

// Request is the minimum context needed before issuing a bearer URL.
type Request struct {
    UserID     string
    ShipmentID string
    ObjectKey  string
    ExpiresAt  time.Time
}

func CanIssue(r Request, ownsShipment bool, now time.Time) bool {
    if r.UserID == "" || r.ShipmentID == "" || r.ObjectKey == "" {
        return false
    }
    if !ownsShipment || !r.ExpiresAt.After(now) {
        return false
    }
    return true
}

The judgment is not whether this function compiles. It is whether every denied decision produces a reason that can be counted without exposing private content. A false positive at this layer blocks a legitimate driver, so page on a sustained SLO breach and route isolated policy mismatches to a lower-severity queue.

How do you make an expiring link a narrow credential?

Step one is to authorize the business action before signing anything. Resolve the authenticated user to the shipment, verify that the requested object belongs to that shipment, and check the operation: upload, preview, or download. An object key supplied by the browser is an identifier to validate, not proof of ownership.

Step two is to issue the narrowest capability the delivery service supports: one object, one operation, one method, and a short expiration appropriate to the workflow. A driver with intermittent connectivity may need more time to upload than a dispatcher needs to download a document. Reusing one lifetime for both is convenient and usually wrong.

Step three is to make replay a deliberate decision. Expiration limits the window; it does not make a bearer credential single-use. If a download must be single-use, put a server-controlled download token in front of the storage URL and record consumption. That adds state and another failure mode, so reserve it for documents whose policy actually requires it.

Step four is to prevent caches from turning a private response into a shared one. The HTTP Cache-Control header communicates caching behavior; private restricts a response to a private cache, while no-store instructs caches not to store it. Choose the directive from the content policy, then verify behavior at the browser, intermediary, and delivery layers. A header in the application response is not evidence that every intermediary behaved as intended.

Here is the shape of a generic link issuer. The storage SDK is intentionally behind an interface; the important contract is authorization, expiration, and auditability.

package links

import "context"

type Signer interface {
    SignDownload(ctx context.Context, objectKey string, expiresSeconds int64) (string, error)
}

func Issue(ctx context.Context, signer Signer, objectKey string, allowed bool, expiresSeconds int64) (string, error) {
    if !allowed || objectKey == "" || expiresSeconds <= 0 {
        return "", ErrDenied
    }
    return signer.SignDownload(ctx, objectKey, expiresSeconds)
}

The code is intentionally boring. The difficult part is deciding what allowed means and proving that the object key was derived from trusted shipment data. Test the negative cases first: another tenant's key, an expired authorization, a mismatched operation, a blank key, and a request made after shipment access was revoked.

Which capacity numbers change the service choice?

Capacity planning starts with the object population, not the upload button. Estimate daily uploads by type, average and p95 object size, retention duration, download frequency, and the fraction that is deleted or archived. Keep a separate estimate for request volume because a large number of small images can create a different request profile from a smaller number of videos.

For example, a planning sheet might model 18,000 delivery records per day, 3 images per record, a 4 MB average image, and 90 days of hot retention. That is an estimate, not a benchmark or a promise about any provider. Add a peak multiplier for dispatch shifts and a retry multiplier for weak mobile links. Then model egress from downloads, because delivery traffic can dominate the storage footprint.

The operational question is what your team can own. Managed object storage usually reduces the amount of replication, durability, and capacity machinery your platform team must operate. Self-hosting can provide control over placement and policy, but it moves failure handling, upgrades, disk replacement, and recovery testing into your on-call budget. A service with a lower published storage rate can still lose the trade if the team must build the missing controls. My capacity-planning reflex is to put that work beside the storage estimate, because an apparently small operational gap can become a permanent on-call obligation when the logistics workload grows across regions, tenants, and retention classes. The trade is deliberate: delivery simplicity is useful only while the access boundary remains reviewable.

That is the trap.

Decision Managed object storage Self-hosted object storage
Capacity Provider handles underlying expansion; validate quotas and request limits Team plans disks, replicas, headroom, and replacement capacity
Access boundary Signed requests and policy integration need explicit review Team owns gateway, signing, identity, and key rotation
Delivery simplicity Direct browser and download paths are usually shorter More control, but more components sit in the path
On-call load Focus shifts to policy, telemetry, and provider limits Includes storage health, recovery, upgrades, and network paths
Lock-in Export format, API compatibility, and egress policy need review Migration is yours, including metadata and operational tooling

I do not use price as the headline decision. I use it after the access model, SLO, recovery objective, and expected egress are written down. Otherwise the spreadsheet rewards a number that the architecture cannot safely use. The limitation is clear: this design is not a fit when the team cannot operate the required identity, audit, and recovery controls; in that case, a simpler managed boundary is the better engineering choice even if it gives up placement control. The downside of direct browser delivery is equally clear when every byte must pass through a policy gateway for inspection, because the extra hop changes the latency and capacity model.

How should the alert close the loop?

The original page should become a trace, not a mystery. Correlate the upload authorization, storage confirmation, link issuance, and download result with one request id and a shipment-scoped audit id. Track expired-link responses, denied authorization decisions, object-not-found responses, and successful first-byte delivery as separate counters. Aggregate by tenant and operation before setting a page threshold.

Run failure tests at three boundaries: policy denial, storage rejection, and delivery interruption. Also test expiry around the boundary, clock skew, revoked shipment access, duplicate browser retries, and a link copied into a different account session. The expected result is not that every retry succeeds; it is that the system gives the user a safe next action and gives the operator a reason that can be acted on.

Keep the alert conservative. A threshold that is too low trains the team to ignore pages during every busy shift; a threshold that is too high leaves private delivery failures invisible until dispatch calls. The cost of a false positive is operational attention, while the cost of a false negative can be a missed document and an unreviewed access-control regression. Set both budgets explicitly.

The decision rule is therefore straightforward: choose the simplest storage path that can enforce object-level authorization, support the required browser workflow, meet the download SLO, expose enough telemetry for the failure modes above, and fit the team's recovery responsibilities. Only then compare durable storage, request, and egress costs.

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.