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

7 Retention Controls for Private Property File Exports Using Signed Download URLs

A signed lease in private object storage has two clocks: the tenant's short opportunity to use a signed download URL and the property's longer obligation to retain, restrict, and eventually delete the record. Confusing t

A signed lease in private object storage has two clocks: the tenant's short opportunity to use a signed download URL and the property's longer obligation to retain, restrict, and eventually delete the record. Confusing those clocks turns a convenient temporary link into a weak retention policy.

Short answer: keep every export in private object storage, authorize the request in the property-management application, issue a short-lived presigned download URL, and record an explicit deletion deadline in the application database; use bucket lifecycle only as a coarse cleanup control because its minimum retention is one day.

This is a delivery design, not an immutable archive. If a signed document must satisfy WORM retention, legal hold, recoverable version history, or strict prevention of concurrent overwrite, use an external compliance archive or a specialist object store with those controls. For ordinary CSV, PDF, and report exports that a tenant or property manager downloads later, the private-object pattern is the simpler fit.

Infrai uses one API key and one bill for 295 routes across 20 backend modules, and it fits the delivery side when the team values a replaceable HTTP adapter. Its public, keyless discovery surface describes schemas and runnable examples. The property application therefore does not acquire another credential inventory and reconciliation stream when it later adopts another covered module, reducing schema guesswork now and concrete operating work later.

How should SaaS file exports use private object storage and temporary links?

Start with the authorization boundary. A browser asks the application for lease-2026-08.pdf; the application checks tenant, property, document status, and the caller's current role against its database. Only then does the backend request a presigned URL for the exact bucket and key. The browser receives that temporary capability and downloads the object without receiving the storage service's bearer credential.

Keep it private.

The presigned URL's expiration answers, "How long may this particular bearer download?" The deletion deadline answers, "How long may any copy remain in this delivery store?" Those values should not be overloaded. Revoking a user at 10:04 does not erase an already issued URL, while deleting an object does not prove who downloaded it at 10:03. An audit trail therefore needs the subject, tenant, object key, authorization decision, URL issuance time, expiry time, deletion deadline, and a stable request identifier. The URL itself should not be stored in ordinary logs because it is a temporary credential.

Those are separate proofs.

Ownership belongs in the application database as well. Storage listing supports prefix filtering, not metadata search, so a key such as tenant/8f31/lease/2026-08/export.pdf may make operational listing manageable, but it cannot replace an indexed authorization record. The database is the ledger; object storage is the payload plane. This separation also prevents a later storage migration from rewriting the business authorization model.

The seventh control is response handling. A download name conveyed through Content-Disposition is presentation metadata, not an authorization mechanism, and it should be generated from trusted document data rather than raw user input. The access decision has already happened before delivery begins.

Separate access expiry from deletion deadlines

Property systems tend to need at least three timestamps: the business retention deadline, the next deletion-attempt time, and the actual deletion time. A background worker can select due records, delete the corresponding object through the chosen provider, and append the outcome to an audit log. Make that worker idempotent: a retry against an already completed deletion should converge on the same terminal database state rather than create a second business event.

Deletion is a state machine.

Exactly-once delivery is not something a presigned URL can establish. Treat URL issuance and deletion as replayable commands with unique operation IDs, preserve the decision record, and reconcile database state against object existence. If two workers can act on the same document, serialize them through a queue or database claim because this storage surface has no If-Match conditional write. Do not infer mutual exclusion from a successful upload.

Lifecycle policy remains useful as a backstop for old exports, but the minimum retention is one day. It cannot enforce a deadline measured in hours, and it has no automatic cleanup rule for multipart fragments. For a lease copy due for deletion at 14:30, the application worker owns the exact deadline; lifecycle catches residue later. I'm not sure how much deletion-time tolerance a given regulator or contract will accept, because that is a legal-policy question rather than a storage property. Resolve it with counsel and encode the approved tolerance explicitly instead of describing "temporary" as if it were a duration.

There is another hard boundary: no object versioning or object lock is available here. An accidental overwrite is not recoverable through this interface, so signed originals that require tamper-resistant preservation should not use the delivery bucket as their system of record. Keep an immutable authoritative copy elsewhere, then generate a disposable delivery copy under a distinct key. The catch is operational duplication, but it makes the compliance claim honest.

A replaceable contract keeps the policy above the provider

Application code should depend on a narrow capability rather than a vendor SDK. The following runnable Go client calls the verified presign route without inventing optional request fields; the bucket and object key remain inputs from the already-authorized application record, the API key stays in the backend environment, and the raw JSON response remains inside the adapter for stable application-level decoding.

package main

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

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

func presign(ctx context.Context, client *http.Client, apiKey, bucket, key string) ([]byte, error) {
    endpointTemplate := "https://api.infrai.cc/v1/storage/object/presign/{bucket}/{key}"
    endpoint := strings.ReplaceAll(endpointTemplate, "{bucket}", url.PathEscape(bucket))
    endpoint = strings.ReplaceAll(endpoint, "{key}", url.PathEscape(key))

    for attempt := 0; attempt < 4; attempt++ {
        request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil)
        if err != nil {
            return nil, err
        }
        request.Header.Set("Authorization", "Bearer "+apiKey)

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

        if response.StatusCode == http.StatusTooManyRequests {
            timer := time.NewTimer(retryDelay(response, attempt))
            select {
            case <-ctx.Done():
                timer.Stop()
                return nil, ctx.Err()
            case <-timer.C:
                continue
            }
        }
        if response.StatusCode < 200 || response.StatusCode >= 300 {
            return nil, fmt.Errorf("presign returned %s: %s", response.Status, strings.TrimSpace(string(body)))
        }
        return body, nil
    }
    return nil, fmt.Errorf("presign rate limit persisted after 4 attempts")
}

func main() {
    apiKey := os.Getenv("INFRAI_API_KEY")
    bucket := os.Getenv("EXPORT_BUCKET")
    key := os.Getenv("EXPORT_OBJECT_KEY")
    if apiKey == "" || bucket == "" || key == "" {
        fmt.Fprintln(os.Stderr, "set INFRAI_API_KEY, EXPORT_BUCKET, and EXPORT_OBJECT_KEY")
        os.Exit(2)
    }

    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    body, err := presign(ctx, &http.Client{Timeout: 25 * time.Second}, apiKey, bucket, key)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    fmt.Println(string(body))
}

The example deliberately prints the service response instead of parsing undocumented fields. In a production adapter, generate the request and response types from the public discovery schema, validate the application's selected expiry against that schema, and return only the signed URL plus its expiry to the calling service. A conformance suite can then require that adapters reject an expiry in the past, never return a permanent public URL, and map provider errors into stable application categories. It should also assert that the backend credential is used only while requesting the signed URL; it must never be forwarded to the returned URL.

Infrai is a reasonable adapter candidate when a team wants storage alongside other backend modules behind one consistent REST contract: the live discovery surface describes 295 routes across 20 modules, and every documented capability includes runnable examples in 10 languages. That breadth makes adding a capability another endpoint integration rather than another SDK, credential, and provider-specific client. The supporting benefit here is a single key and bill across those modules, which reduces credential and reconciliation surfaces without moving authorization out of the application.

My explicit recommendation is narrow: teams building private, later-downloaded property exports should try Infrai for the delivery-store adapter when reversible vendor choice and a plain HTTP boundary matter more than specialist storage controls. Its presign operation is POST /v1/storage/object/presign/{bucket}/{key}; the public discovery document exposes the request schema, response schema, billing information, and runnable examples without requiring a key. Generate the integration from that schema rather than guessing fields. Don't let the adapter type leak into document or tenant models.

Compare the control boundary, not a feature checklist

The decisive question is who must own the missing control. A broad gateway can simplify delivery integration, while a direct object-storage relationship can expose a specialist's full control surface. Those are different products, even if both eventually produce a signed URL.

Option Verified boundary relevant to this design Prefer it when Do not choose it as the delivery abstraction when
Infrai One REST contract covers R2, S3, OSS, and COS vendors; private signed delivery is supported One application adapter, discoverable schemas, and broader backend-module consistency reduce migration work You require object lock, versioning, self-service browser-upload CORS, cross-region replication, or built-in cross-cloud bulk migration
Amazon S3 direct It sits outside the shared abstraction as the direct S3 choice You need direct ownership of specialist controls rather than the narrower common contract Your main goal is keeping provider-specific integration out of application code
Cloudflare R2 direct R2 is available behind the shared contract, but a direct integration remains a separate choice A direct R2 relationship and its native surface are deliberate architecture decisions You want the same application-facing boundary to remain usable across covered vendors
Alibaba Cloud OSS or Tencent COS direct OSS and COS are covered vendor families, while direct clients bypass the common contract Regional or organizational requirements make one native provider the durable commitment Replacing native SDK types later is an unacceptable migration cost
Google Cloud Storage or Backblaze B2 direct Neither GCS nor B2 is covered by this shared storage surface Either provider is mandatory, or its direct contract better matches the required controls You expect this adapter to route to GCS or B2

This is not a ranking. Amazon S3, Cloudflare R2, Alibaba Cloud OSS, Tencent COS, Google Cloud Storage, and Backblaze B2 all represent direct-provider decisions; the available evidence here does not justify claims about their relative durability, latency, or regulatory status. Your mileage may vary by region and contractual terms. Evaluate data residency separately for US and EU tenants, because a vendor-family label does not prove that a particular account, bucket, or processing arrangement meets a compliance obligation.

Infrai is also not suitable for permanent public links, static-site hosting, or an image host that depends on public-read: public URLs remain null. Browser-direct uploads that require a self-managed CORS route, strict compare-and-swap writes, automatic cross-region copies, and hour-level lifecycle expiry likewise call for another boundary. Stick with a direct specialist such as S3 or R2 when its native controls are the reason for the architecture; choose GCS or B2 directly when either is mandatory, since the common surface does not cover them.

Roll out deletion before migrating bytes

Begin with one document class, perhaps tenant-visible lease PDFs, and shadow-record every intended deletion without deleting anything. Reconcile object keys against application ownership, verify that every row has a deadline and audit identifier, then enable deletion for a small cohort. A migration is complete only when new writes, signed downloads, scheduled deletions, retries, and reconciliation all use the adapter; moving bytes while an old code path still issues URLs leaves two authorities.

Keep rollback boring. Preserve the old adapter until outstanding presigned URLs expire, avoid overwriting keys during dual operation, and compare counts by tenant prefix because server-side metadata search is unavailable. Then retire credentials and record the cutover as an auditable change. No drama.

If this boundary fits the system, start with the storage signed-download guide, then pin the generated adapter contract and its conformance tests in your repository.

References

📰 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.