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

A Failure-Budget Test for Signed URL Providers, Document Egress, and Cost

The answer is: compare object storage providers for signed URL private document downloads by testing authorization lifetime, revocation, egress cost, and operational ownership under your own traffic model; don't pick S3,

The answer is: compare object storage providers for signed URL private document downloads by testing authorization lifetime, revocation, egress cost, and operational ownership under your own traffic model; don't pick S3, R2, Supabase Storage, or Firebase Storage from a feature checklist alone. A link that works in a demo can still fail the production question: can the system deliver the right bytes to the right user, inside the download SLO, without leaving an unbounded bill or an on-call mystery? Treat the signed link as a short-lived capability, not as proof that the caller should still have access. That distinction drives the architecture. It also changes the comparison from β€œwhich API is shortest?” to β€œwhich failure boundary can this team operate?”

The incident lesson starts at the authorization boundary

If I'm paged into a bounded production scenario, this is the one I want rehearsed: at 10:00 a user opens a private document in one browser tab; at 10:03 the URL lands in a support ticket; at 10:07 an administrator removes that user's access; and at 10:09 the original link is requested again before its 15-minute expiry. The object store may accept a cryptographically valid request while the application now considers the user unauthorized. Nothing is necessarily broken. The two systems are answering different questions at different times. The drill should then follow every copy rather than stop at the browser: inspect whether query strings reached proxy logs, whether analytics captured the full location, whether the ticket was moved into a wider queue, and whether the signing event can be reconstructed from a decision ID without recovering the secret itself. Next, repeat the request at 10:14 and 10:16, confirm the exact expiry boundary with a fake clock, and verify that a fresh request after revocation is denied before the signer is called. This is a controlled exercise, not a claim about a particular provider, and its purpose is to expose the policy gap while the team can still change the design.

That gap is the invariant. A signed URL delegates access for its lifetime; it doesn't continuously consult the application's current policy. Shortening the lifetime narrows exposure, but it also increases requests to the signing service and makes clock skew, slow clients, retries, and large downloads more visible. Lengthening it reduces signer load and improves tolerance for delayed clicks, at the price of a larger revocation window. There is no universal duration hidden in a provider comparison. The duration belongs in the threat model and the SLO.

Now make the exercise less tidy. The client retries after a network interruption. A proxy logs the full query string. The user forwards the link. The authorization service is healthy but the signing dependency is unavailable. A 403 reaches the browser, yet the support ticket contains no request ID that joins the application decision to the storage request. Those aren't exotic corner cases; they're the ordinary consequences of passing a bearer capability through browsers, logs, queues, and support tooling. I think teams often test the happy GET and call the design finished because every layer is individually behaving as configured.

Don't.

The preventative control is a download broker that authenticates the user, re-evaluates document policy, records a decision ID, and only then returns a narrowly scoped, short-lived link. The broker should avoid placing the resulting URL in analytics events or application logs. For unusually sensitive material, proxying the bytes through the application can provide immediate revocation and centralized audit, but it transfers bandwidth, latency, connection management, and scaling pressure to the application tier. That's a real buy-vs-build trade, not a security freebie.

How should signed URL providers handle private document downloads and egress?

They shouldn't be judged by URL signing alone. Use the same acceptance suite against every candidate and record evidence rather than impressions. S3 presigned URL, R2 signed URL, Supabase Storage, and Firebase Storage are labels in the reader's shortlist; the useful comparison is the behavior your application can observe at the boundary.

Decision axis Test to run Evidence to retain Failure-budget question
Authorization Issue a link, change app access, then replay it at defined intervals Policy decision, issue time, expiry, object identity How much stale access can the threat model tolerate?
Delivery Exercise full downloads, ranges, cancellation, and retry Latency histogram, completion rate, bytes served Which events consume the download error budget?
Egress cost Replay the expected geography and cache-hit mix Billed bytes, request classes, cache behavior What traffic shape breaks the monthly budget?
Audit Trace one user action through signer and object request Correlation ID and redacted logs Can on-call explain a denial without exposing the URL?
Upload path Test small uploads and the large-file path separately Part state, retry count, cleanup evidence Who owns abandoned work and partial state?
Exit Export data and metadata into a neutral inventory Checksums, content types, policy mapping How long would a provider move actually take?

Run that suite from the regions and client types that matter. β€œEgress” is not one scalar when downloads may cross geography, pass through a cache, be retried, or be proxied by your own service. Build a monthly model from stored bytes, newly written bytes, successful downloaded bytes, retry amplification, request count, cache misses, and operational labor. Keep each term visible. If a quote or pricing page changes, you should be able to replace one input instead of rebuilding the argument.

Your mileage may vary because document size distribution matters more than an average suggests. A workload with many tiny statements stresses request rate and metadata paths; a workload with multi-gigabyte archives stresses range behavior, client retry, connection duration, and upload recovery. Capacity planning should use percentiles and concurrency, with at least one burst case, rather than β€œmonthly traffic divided by seconds in a month.” The latter produces a reassuring number that no user ever generates.

I'm not sure which candidate wins without those workload inputs, and nobody else should be sure either.

Put the policy in code before comparing dashboards

The application-facing contract can stay generic even while each adapter implements a different signing mechanism. That reduces the amount of vendor-specific behavior allowed to leak into business logic. It also gives tests a stable point at which to assert tenant isolation, expiry bounds, disposition, and audit metadata.

Here is the broker-side policy path I would review. It intentionally leaves provider signing behind an interface; copying a vendor algorithm from a blog post would create a second security implementation to maintain, while the adapter can use the provider's documented mechanism. The handler never logs the returned URL.

package download

import (
    "context"
    "encoding/json"
    "errors"
    "net/http"
    "time"
)

type Request struct {
    UserID     string
    TenantID   string
    DocumentID string
}

type Decision struct {
    Allowed    bool
    ObjectKey  string
    DecisionID string
}

type Authorizer interface {
    CanDownload(context.Context, Request) (Decision, error)
}

type Signer interface {
    SignDownload(context.Context, string, time.Duration) (string, error)
}

type Audit interface {
    RecordDecision(context.Context, Request, Decision, time.Time) error
}

type Handler struct {
    Auth   Authorizer
    Signer Signer
    Audit  Audit
    TTL    time.Duration
    Now    func() time.Time
}

func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
        return
    }

    var req Request
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "invalid request", http.StatusBadRequest)
        return
    }

    decision, err := h.Auth.CanDownload(r.Context(), req)
    if err != nil {
        http.Error(w, "authorization unavailable", http.StatusServiceUnavailable)
        return
    }
    if !decision.Allowed {
        http.Error(w, "forbidden", http.StatusForbidden)
        return
    }
    if h.TTL <= 0 {
        http.Error(w, "invalid download policy", http.StatusInternalServerError)
        return
    }

    issuedAt := h.Now().UTC()
    if err := h.Audit.RecordDecision(r.Context(), req, decision, issuedAt); err != nil {
        http.Error(w, "audit unavailable", http.StatusServiceUnavailable)
        return
    }

    url, err := h.Signer.SignDownload(r.Context(), decision.ObjectKey, h.TTL)
    if err != nil {
        http.Error(w, "signing unavailable", http.StatusServiceUnavailable)
        return
    }

    w.Header().Set("Cache-Control", "no-store")
    w.Header().Set("Content-Type", "application/json")
    _ = json.NewEncoder(w).Encode(struct {
        URL        string    `json:"url"`
        ExpiresAt  time.Time `json:"expires_at"`
        DecisionID string    `json:"decision_id"`
    }{url, issuedAt.Add(h.TTL), decision.DecisionID})
}

var ErrDenied = errors.New("download denied")

The exact HTTP status exposed to an untrusted caller needs a security review; returning different responses for β€œmissing document” and β€œdocument owned by another tenant” may disclose existence. Internally, keep those reasons distinct. Externally, keep the response boring, attach a correlation identifier, and redact the capability itself everywhere. Test the adapter with a fake clock so a one-second boundary isn't a flaky wall-clock assertion.

Upload behavior belongs in this review even though the user sees downloads. Large user documents may need multipart upload, with independently uploaded parts and a completion step; AWS documents that lifecycle in its multipart upload overview. That creates operational questions about retries, incomplete work, checksums, and cleanup. A provider that looks equivalent during a small-file download test may impose a very different application contract once ingestion, repair, and migration are included.

Buy, build, or proxy the download path

The decision is partly about who gets paged. A managed signing path can reduce code and infrastructure ownership, while a self-hosted gateway offers tighter policy control and portability but puts bandwidth and connection behavior on your team. A byte proxy is not suitable when the application tier cannot absorb peak transfer volume. Direct-to-storage delivery is not suitable when immediate revocation or mandatory inline inspection outweighs that capacity cost.

Approach You operate Useful when The catch is
Managed direct delivery Policy broker and provider adapter Short-lived delegation matches the risk model Provider semantics, billing inputs, and migration work remain external dependencies
Self-hosted signing gateway Credentials, signing code, deployment, and on-call A stable internal contract matters across storage backends Security maintenance and compatibility testing become permanent work
Application byte proxy Authorization plus the full data plane Every request must pass current policy or inspection Peak bandwidth, latency, retries, and connection limits move into your SLO

Stick with direct delivery when bounded stale authorization is acceptable and the acceptance suite proves the client behavior. Choose a proxy when policy must be checked on every transfer and the platform team has budgeted its data-plane capacity. Build a signing gateway only when the control gained is worth owning cryptographic integration and compatibility over the system's lifetime. β€œWe can write it in a sprint” is not a lifecycle estimate.

Cost belongs beside those ownership columns, not above them. Preserve the date and units for every pricing input in the model rather than turning a changing external number into an application constant. Don't claim a winner from a single egress rate. Request charges, retained capacity, geography, cache behavior, support, engineering time, and exit work can reverse a narrow comparison.

The final review should produce three artifacts: a tested application contract, a capacity model with explicit assumptions, and a runbook that can explain denials without revealing bearer links. Re-run the suite before a provider change and after any policy change. The winner is the design whose failure modes fit the threat model, error budget, and team ownership boundary; if those inputs change, the decision should be allowed to change too.

Sources

πŸ“° 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.