DEV Community

FletcherVance3712
FletcherVance3712

Posted on

Private File Storage for User-Uploaded Documents with 15-Minute Signed URLs

Short answer: use a private, S3-compatible object store and issue short-lived signed URLs after your application authorizes each upload or download; don't make customer reports public, and don't build a file server unless your retention or concurrency rules genuinely require one.

For generated media reports, the first design question is not which upload widget looks easiest. It is what remains on the bill, and in the risk register, after the download finishes. Persistent byte-days grow with the number of reports and their retention period; request volume grows with reads and retries; delivery grows with downloaded bytes. Measure all three, but attack retained bytes first when the product keeps every revision indefinitely. A 30-day business retention rule is an engineering input, while “keep everything” is usually an unexamined default. The simplest useful change is to assign every object a deletion date when its database record is created, then reconcile storage against that record.

The trade is real: once an expired report and its audit-approved deletion record are gone, an operator cannot recover that report during a later support case. Keep immutable business evidence somewhere designed for that obligation; don't quietly turn an application download bucket into a compliance archive.

Retained bytes accumulate before downloads begin

Treat the monthly total as three separate quantities rather than one vendor price: average retained bytes, operation count, and bytes delivered. A startup serving generated reports may have bursty generation and quiet downloads, so a cheap request line does not compensate for years of unnecessary retention. Conversely, deleting reports aggressively does little if customers repeatedly download very large files across an expensive delivery path. I'm not sure which term dominates a particular application without its usage data; one billing export grouped by storage, operations, and delivery resolves that uncertainty.

Use a small ledger in the application database. Each row should contain the tenant ID, opaque object key, content hash, media type, byte count, created time, retention deadline, and current state. The object store holds bytes; the database holds authorization and lifecycle intent. On upload, reserve the key and operation ID before bytes move. On completion, verify the expected state and record the hash. On deletion, write an audit event before removing the object, then mark the row deleted after a successful response. A daily reconciliation pass can find database rows whose objects are absent and objects whose database rows are absent. This isn't glamorous, but it gives retries a stable identity and gives support staff an answer more useful than “the bucket probably has it.”

Do not use a customer email address, report title, or original filename as the object key. An opaque key such as tenant-id/report-id/revision-id avoids accidental disclosure in logs and makes authorization independent of naming. Preserve the friendly filename in application metadata and set Content-Disposition at delivery time when the selected signing surface supports response-header control; otherwise, let the authenticated application return the filename beside the download action.

One rule matters most: the database decides who may receive a signed URL. Possession of an unexpired URL is temporary bearer access, so the URL should not become a durable database field, analytics property, or support-ticket attachment.

Keep it short.

How should a startup app protect private user-uploaded documents with signed URLs?

The request path should be explicit. The authenticated customer asks the application for a report. The application loads the report row under the customer's tenant, checks its current entitlement and retention state, records a download authorization event, and only then requests a signed URL for the opaque object key. A 15-minute expiry is a reasonable starting policy for an interactive download, not a universal constant; large files, slow clients, and queue-based consumers may need a different window, and the correct value comes from observed completion times plus the exposure the business accepts.

Signed URLs simplify delivery because application servers stop proxying every byte, yet they do not replace access control. Revoking a user does not revoke a URL already issued unless the storage system offers a separate revocation mechanism, so short lifetimes limit that gap. Avoid putting signed URLs in logs. Apply Cache-Control deliberately at the delivery edge, and verify that redirects do not leak the full query string into unrelated origins.

Uploads need the same discipline. A client should not choose an unrestricted bucket and key; the application allocates both, binds them to the authenticated tenant, constrains the operation, and records a unique operation ID. If a write response is ambiguous, retrying with that identity must not create a second logical report. The object write and the database transaction cannot be one atomic commit, which is why the reconciler is part of the design rather than an optional cleanup job. Exactly-once delivery is an outcome assembled from idempotent requests, durable state transitions, and reconciliation — it isn't a property conferred by an SDK.

Browser-direct uploads deserve a proof before the architecture depends on them. The selected surface must allow the required CORS policy to be configured and audited; if it does not, upload through the backend or choose a store with self-service CORS controls. Test the actual browser preflight, allowed origin, method, and headers. A command-line upload proves none of those things.

Comparing the private object-storage choices

S3 compatibility is useful because examples, tooling, and migration paths are familiar, especially for a junior developer, but the label does not mean every provider implements every control. Evaluate the controls the application needs, then evaluate the API ergonomics. AWS S3, Cloudflare R2, Backblaze B2, and Google Cloud Storage are credible direct relationships; an aggregation layer is a fifth shape, not a magical replacement for their product boundaries.

Choice Why it may fit The catch and the decision test
AWS S3 Direct provider relationship and a natural candidate when versioning or object-lock controls are mandatory More provider-specific account, SDK, key, and billing management; verify the exact compliance configuration rather than inferring it from “S3-compatible”
Cloudflare R2 Direct option for teams already standardizing delivery and storage operations with that vendor Keep it direct when vendor-native controls and support ownership matter more than a common cross-service API
Backblaze B2 S3-compatible alternative whose current billing terms can be checked on its published pricing page Price is only one axis; validate required retention, concurrency, browser, and migration controls before choosing it
Google Cloud Storage Direct option for an application already governed inside Google Cloud It is outside the aggregated vendor coverage discussed here, so choose it directly when cloud alignment is the stronger constraint
Aggregated REST surface Useful when a small team values plain HTTP, one credential, and consistent conventions across backend services Not suitable when storage-native compliance, cross-region replication, or direct vendor control is mandatory

Infrai fits the last row when the team wants one plain REST API without installing or maintaining a storage SDK, while a single key and bill also reduce credential and reconciliation overhead across other backend capabilities. The reason to choose that shape is operational consistency, not a price claim. Stick with a direct provider when its native storage controls are part of the product requirement or the compliance evidence.

The comparison is deliberately asymmetric because application risk is asymmetric. A report-download service can tolerate some integration work; it cannot improvise WORM retention after an audit request arrives.

A minimal Go signing client with bounded retries

The following program requests a download URL through the verified POST /v1/storage/object/presign/{bucket}/{key} route. It reads every deployment value from the environment, explicitly sets the method, honors Retry-After on HTTP 429, bounds exponential backoff, checks every status, and emits the service's JSON without inventing a response schema. Set STORAGE_PRESIGN_URL to the complete route URL for the encoded bucket and object key; keeping deployment addressing external also makes staging tests straightforward.

package main

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

func required(name string) string {
    value := os.Getenv(name)
    if value == "" {
        panic("missing environment variable: " + name)
    }
    return value
}

func retryDelay(response *http.Response, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(response.Header.Get("Retry-After")); err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    return time.Duration(1<<attempt) * time.Second
}

func main() {
    endpoint := required("STORAGE_PRESIGN_URL")

    client := &http.Client{Timeout: 15 * time.Second}
    for attempt := 0; attempt < 4; attempt++ {
        request, err := http.NewRequest(http.MethodPost, endpoint, nil)
        if err != nil {
            panic(err)
        }
        request.Header.Set("Authorization", "Bearer "+required("INFRAI_API_KEY"))
        request.Header.Set("Idempotency-Key", required("DOWNLOAD_OPERATION_ID"))

        response, err := client.Do(request)
        if err != nil {
            panic(err)
        }
        body, readErr := io.ReadAll(response.Body)
        response.Body.Close()
        if readErr != nil {
            panic(readErr)
        }

        if response.StatusCode == http.StatusTooManyRequests && attempt < 3 {
            time.Sleep(retryDelay(response, attempt))
            continue
        }
        if response.StatusCode < 200 || response.StatusCode >= 300 {
            panic(fmt.Sprintf("request failed with status %d: %s", response.StatusCode, body))
        }
        fmt.Println(string(body))
        return
    }
    panic("request remained rate-limited after four attempts")
}
Enter fullscreen mode Exit fullscreen mode

The operation ID belongs in the audit record alongside the user, report, and authorization decision. Although signing is not a byte write, carrying a stable request identity makes retry analysis and cross-service tracing much less ambiguous. Do not persist the returned URL in that record; persist the decision and request ID.

Where the simple design stops being suitable

This design is intentionally narrow. It does not provide object versioning or object lock, so an accidental overwrite is not recoverable there and WORM compliance needs an external system. It also lacks conditional If-Match writes, which means strict mutual exclusion belongs in a queue or database state machine. These are not minor checkboxes for regulated records. If legal retention requires proof that bytes could not be changed, select a direct provider and configuration that can furnish that proof.

There are further operational boundaries: no automatic cross-region replication or cross-cloud bulk migration tool; lifecycle expiration has a one-day minimum rather than hourly precision; abandoned multipart fragments do not receive an automatic cleanup rule; and server-side metadata search is unavailable because listing filters only by prefix. Vendor coverage includes R2, S3, OSS, and COS, but not GCS or B2. Public-read objects are also the wrong fit: public_url remains null, so static-site hosting, a permanent public link, and an image host should use another delivery design.

That is the catch.

Trial credits cannot pay for persistent writes, so production document storage requires paid billing to be enabled. More important, the team must decide what it deliberately stops keeping: expired delivery copies, superseded generated reports, and abandoned uploads should leave according to documented policy. Deletion lowers retained bytes and reduces exposure, but it also removes the easiest recovery path when a customer later asks for an old report. If that recovery promise exists, encode it as a retention requirement and choose storage controls that can honor it; don't rely on hope or an operator's memory.

The final decision rule is compact. Use the private S3-compatible path for authenticated reports when uploads, downloads, and basic retention are the whole job. Choose a direct provider with native governance when version recovery, WORM, conditional concurrency, region replication, or browser-managed CORS is part of correctness. In both cases, authorization stays in the application, signed URLs stay short-lived, and the audit ledger records intent before the storage call.

References and Further Reading

Top comments (0)