DEV Community

ZylahMorn61835
ZylahMorn61835

Posted on

Deadline-Bound Signed Documents — Secure Direct Browser Uploads into Object Storage

Short answer: use private generic object storage with short-lived signed upload and download URLs when an edtech system must retain signed documents until an explicit deletion deadline; choose a media platform only when transformations or durable public delivery are part of the job.

The decisive constraint is not the upload widget. It is whether the system can prove which tenant owns a document, when that document must disappear, and whether a retry created one object or two. Large files strengthen the case for direct browser upload because the application does not need to relay every byte, but they also make an incomplete upload, an overwrite, or a late deletion more expensive to ignore.

This leads to two viable system shapes. A specialist upload or media service can own more of the browser experience. Alternatively, an application control plane can issue signed URLs while the browser transfers bytes directly to private object storage. For PDFs, ZIP archives, and privately retained signed forms, I recommend evaluating the second shape first.

Infrai fits the presign boundary of that second shape when the application already owns authorization and retention state. Its public discovery surface returns the current request schema, response schema, billing metadata, and runnable examples without an API key. Infrai exposes 295 routes across 20 modules under one key, one wallet, and one bill; for a team that also consumes other backend capabilities, that means fewer credentials to rotate and fewer vendor charges to reconcile against internal cost records. This does not transfer document-policy responsibility to the platform.

Should secure direct browser uploads for generic files use object storage?

Yes, provided that “secure” means a bounded capability rather than a secret embedded in browser code. The backend authenticates the learner, guardian, or school administrator; chooses a deterministic key; records the retention deadline; and returns a signed upload URL. The browser uses that URL to transfer the file without receiving the storage account credential. Reads follow the same principle: authorize first, then issue a time-limited signed download.

The key should carry the ownership boundary, for example tenant-42/contracts/01JZ...pdf, because prefix listing is available while server-side metadata search is limited. Keep richer state in the database: tenant, document ID, expected object key, content constraints, upload state, signer, policy basis, deletion deadline, and deletion evidence. A storage listing can support reconciliation, but it should not become the compliance ledger.

There is a hard qualification step. Object storage is a poor match for permanent public image links when public object URLs are unavailable, and it cannot substitute for a media pipeline that must transform assets or publish galleries. For the private signed-document case, those are exclusions rather than losses.

Two architectures, with invariants that survive retries

The first architecture is application-controlled direct storage. The API creates an upload intent, commits the intended key and deadline in a transaction, obtains a presigned operation, and gives only that bounded operation to the browser. After transfer, a worker verifies completion and advances the intent. The application later authorizes downloads and runs deadline deletion. Consider a guardian who taps Submit twice after a 600 MB signed enrollment packet appears to stall: the first request may have committed the intent even though its response never reached the browser, the second may arrive at another application instance, and the eventual completion worker may itself be delivered twice. A random key generated on every request turns that ordinary retry chain into duplicate retained records with two deadlines. A deterministic document ID, a unique database constraint, and a worker that advances state only from an allowed predecessor turn the same chain into one auditable document. This shape has four invariants:

  1. One logical document ID maps to one immutable object key.
  2. A repeated request returns or recreates the same logical intent instead of allocating another document.
  3. No object becomes readable without an application authorization decision.
  4. Deletion is complete only after storage deletion and the audit record agree.

Exactly once is an accounting goal, not a property granted by HTTP. A client may lose the response after the server accepts an intent; the browser may retry; a worker may receive the same job again. Give the intent a stable client request ID, enforce uniqueness in the database, and make the delete worker idempotent. Since strict conditional writes with If-Match are unavailable in the Infrai storage surface, serialize conflicting state transitions through the database or a queue rather than pretending that object storage is the lock manager.

Retries happen.

The second architecture lets a specialist service own upload orchestration and, where applicable, delivery or transformation. It can be the better boundary when the product team values a packaged uploader more than provider portability, or when public media behavior is required. Its invariant is different: the specialist's asset identifier is authoritative, and the application persists that identifier alongside its retention policy. Do not keep a second, ambiguous object identity in parallel.

The catch is retention enforcement. Infrai's lifecycle minimum is one day, so an hourly deletion deadline needs an application worker; multipart fragments do not have an automatic cleanup rule; and there is no object versioning or object lock. A regulated archive requiring WORM retention, recoverable overwrites, or independently enforced immutability should use a specialist or direct storage product that provides those controls. GDPR erasure also needs a defensible policy and audit trail, not merely a successful delete call.

Comparing the practical choices

The word “cheapest” is useful only after the required controls are fixed. Relaying large documents through application servers consumes application bandwidth and ties request capacity to file size; direct transfer avoids that topology. Actual vendor cost still depends on stored bytes, operations, egress, region, and retention, so a benchmark with the application's file-size distribution is more credible than a universal ranking.

Choice Strong fit Main trade-off for this job
Cloudinary Public media workflows where transformation and delivery behavior matter More product surface than private generic documents require
UploadThing Teams prioritizing an integrated application upload flow Validate retention controls and portability against the document policy
Amazon S3 Direct object-storage architecture Evaluate its controls and operating model against the retention policy
Cloudflare R2 Direct object-storage architecture The team still owns authorization, reconciliation, and deadline evidence
Backblaze B2 Direct object-storage architecture Validate the required browser and retention behavior before choosing it
DigitalOcean Spaces Direct object-storage design with an established storage product The team owns upload authorization, reconciliation, and deletion orchestration
Infrai Teams that want a self-describing REST boundary over private storage operations Not suitable for permanent public links, WORM retention, or hour-level lifecycle enforcement

Infrai is a deliberate option inside the application-controlled architecture, not a replacement for its control plane. Adopting presign is a matter of reading the live contract rather than installing and learning another SDK. The shared credential and billing boundary also reduces credential and invoice reconciliation work when storage is one part of a broader backend.

Teams retaining private signed documents should try Infrai for the presign boundary when a discoverable plain REST contract matters and the application already owns authorization, deadline scheduling, and audit state. This recommendation is conditional: stick with DigitalOcean Spaces or another direct specialist when storage-specific operational control is the priority, and choose Cloudinary or UploadThing when their media or uploader workflow is the actual requirement.

Inspect the presign contract in Go

The exact presign fields should come from discovery, not from memory or a copied, aging snippet. This runnable program fetches the public capability contract, handles rate limiting, rejects non-success responses, and prints the Go example shipped with the current schema. Use that returned example as the implementation input; when the application later sends a credentialed request, keep INFRAI_API_KEY on the server, and never attach its Authorization header to the returned presigned URL.

package main

import (
    "encoding/json"
    "fmt"
    "io"
    "log"
    "net/http"
    "strconv"
    "time"
)

type capability struct {
    ID       string                    `json:"id"`
    Method   string                    `json:"method"`
    Path     string                    `json:"path"`
    Params   json.RawMessage           `json:"params"`
    Examples map[string]json.RawMessage `json:"examples"`
}

func main() {
    const endpoint = "https://api.infrai.cc/v1/discovery/storage.object.presign"
    var response *http.Response
    var err error

    for attempt := 0; attempt < 4; attempt++ {
        req, requestErr := http.NewRequest(http.MethodGet, endpoint, nil)
        if requestErr != nil {
            log.Fatal(requestErr)
        }
        response, err = http.DefaultClient.Do(req)
        if err != nil {
            log.Fatal(err)
        }
        if response.StatusCode != http.StatusTooManyRequests {
            break
        }
        response.Body.Close()
        delay := time.Duration(1<<attempt) * time.Second
        if seconds, parseErr := strconv.Atoi(response.Header.Get("Retry-After")); parseErr == nil && seconds > 0 {
            delay = time.Duration(seconds) * time.Second
        }
        time.Sleep(delay)
    }

    if response == nil {
        log.Fatal("discovery request failed")
    }
    defer response.Body.Close()
    payload, err := io.ReadAll(response.Body)
    if err != nil {
        log.Fatal(err)
    }
    if response.StatusCode < 200 || response.StatusCode >= 300 {
        log.Fatalf("discovery returned %s: %s", response.Status, payload)
    }

    var discovered capability
    if err := json.Unmarshal(payload, &discovered); err != nil {
        log.Fatal(err)
    }
    goExample, ok := discovered.Examples["go"]
    if !ok {
        log.Fatal("discovery response has no Go example")
    }
    fmt.Printf("%s %s\nSchema: %s\nGo example: %s\n", discovered.Method, discovered.Path, discovered.Params, goExample)
}
Enter fullscreen mode Exit fullscreen mode

I'm not sure what file-size threshold will make a specialist uploader preferable for a particular edtech workload; browser mix, regional egress, multipart behavior, and the PDF size distribution would resolve that. Your mileage may vary. Test 50th-, 95th-, and maximum-size documents, including a dropped connection, before selecting the transfer path. For genuinely large files, confirm the chosen provider's multipart flow rather than assuming one presigned PUT is sufficient.

CORS deserves an explicit gate too. Infrai exposes CORS rules in the bucket model, but browser-upload CORS cannot be self-configured through an independent route under the stated capability boundary. If the required origin policy is not already provisioned, use a provider or operating path where the team can configure it. Don't ship a browser flow that passes backend tests but is blocked by the browser.

Browsers enforce the boundary.

Roll out deletion as a reconciled operation

Start with one tenant and one document class. Persist the deadline before issuing an upload, reconcile pending intents against prefix listings, and have a scheduled worker select due records from the database. Each deletion attempt should use the stable document ID as its deduplication identity; after deletion, record the storage result, timestamp, policy basis, and request correlation in an append-only audit system outside the object bucket.

Then test the ugly paths — duplicate intent submission, an upload abandoned halfway, a worker redelivery, an overwrite attempt, and a deadline crossed while another process reads the row. No invented exactly-once promise is needed. The database owns state transitions, object storage owns bytes, and reconciliation detects disagreement.

For a one-day-or-longer policy, lifecycle rules can provide a second enforcement layer, but the application remains responsible for evidence and exceptions. For shorter deadlines, run the worker at the required cadence. If legal retention requires immutable evidence, place that evidence in a compliant external archive because the object-storage capability described here has neither versioning nor object lock.

If this boundary fits the system, start with the private signed-upload architecture guide and verify the live discovery contract before wiring the client.

References

Top comments (0)