DEV Community

Haelion14
Haelion14

Posted on

Node.js Presigned URL Audit for Backup Downloads (Object Storage Retention)

A selected backup snapshot should remain private until an authorized tenant asks to restore it. The least complex way to deliver its archive is a short-lived presigned download URL for one object, issued after a tenant-scoped lookup. The deletion timer belongs to the snapshot record, not to the link.

TL;DR: treat a presigned URL as a bearer credential, not delivery proof. Keep the backup object private, authorize the snapshot before signing, record issuance without recording the URL, and alert only when a restore is stalled past evidence you can trust.

For an e-commerce system, this matters after the order database or a tenant configuration has to be rolled back to one selected snapshot. A predictable object key is convenient during an incident. It is also the wrong authorization boundary. The download service needs to resolve an opaque snapshot ID, verify that it belongs to the requesting tenant, and then sign exactly that stored object.

The two clocks are easy to conflate. A signed link might last 10 minutes while a snapshot is retained for 30 days; expiration of the first must not delete the second. The values are policy examples, not universal defaults. Link lifetime is shaped by archive size and retry behavior. Retention is shaped by recovery obligations, legal holds, and the cost of keeping data that should have been deleted.

What should a Node.js service do with a presigned download URL for object storage?

Have its handler authenticate the caller, load the selected snapshot through a tenant-scoped query, and send the resulting object key to a narrow signing interface. A browser must never provide an object key that the signer treats as authorization.

The implementation detail may live in a Node.js service; the boundary is the important part. This Go example makes the boundary testable without coupling the restore workflow to one storage SDK.

package restore

import (
    "context"
    "fmt"
    "net/url"
    "time"
)

type Snapshot struct {
    ID       string
    TenantID string
    ObjectKey string
    DeleteAt time.Time
}

type Presigner interface {
    PresignGet(ctx context.Context, objectKey string, expiresAt time.Time) (string, error)
}

func DownloadURL(ctx context.Context, callerTenantID string, snapshot Snapshot, signer Presigner, now time.Time) (string, error) {
    if callerTenantID != snapshot.TenantID {
        return "", fmt.Errorf("snapshot does not belong to caller")
    }
    if snapshot.ObjectKey == "" || !now.Before(snapshot.DeleteAt) {
        return "", fmt.Errorf("snapshot is unavailable")
    }

    expiresAt := now.Add(10 * time.Minute)
    signedURL, err := signer.PresignGet(ctx, snapshot.ObjectKey, expiresAt)
    if err != nil {
        return "", fmt.Errorf("presign snapshot %s: %w", snapshot.ID, err)
    }
    if _, err := url.ParseRequestURI(signedURL); err != nil {
        return "", fmt.Errorf("signer returned an invalid URL: %w", err)
    }
    return signedURL, nil
}
Enter fullscreen mode Exit fullscreen mode

Presigned URLs can be used more than once until they expire, so retrying a click is not evidence of a duplicate restore. Keep backup creation idempotent on a restore-operation ID, and trace link issuance as a separate action. That distinction prevents a queue retry from creating a second snapshot while still allowing a user to obtain a replacement credential after a tab closes.

Start with the page, then trace backward

The page should say that a restore delivery is stalled, and the on-call view should expose the snapshot ID, tenant ID, issuance time, expiry time, deletion deadline, and last trustworthy delivery state. It should not show the signed URL, query string, or an authorization header. Those fields can be credentials.

Work backward from that page. A download_url_issued event means the service authorized a request and created access. It does not mean that a browser reached storage. download_started means a request reached a boundary that can observe it. A completion event is useful only when the transfer mechanism can truthfully emit one; an application redirect is not completion evidence.

Event What it proves What it cannot prove
download_url_issued Authorization and signing occurred. The archive was reachable or received.
download_started An observable transfer request began. The complete archive arrived.
download_completed A trusted transfer boundary observed completion. The data was restored successfully.

This is where a telemetry change pays for itself. Emit an issuance event after authorization and signing, with one stable event ID for that issuance attempt. Store a derived object identifier rather than the raw key when object names can reveal tenant information. The snapshot ID is usually the better correlation key across export, delivery, and restore stages.

package restore

import (
    "crypto/sha256"
    "encoding/hex"
)

func ObjectKeyID(objectKey string) string {
    sum := sha256.Sum256([]byte(objectKey))
    return hex.EncodeToString(sum[:8])
}
Enter fullscreen mode Exit fullscreen mode

Small signal, big difference. A missed job and a duplicate delivery can otherwise collapse into the same count, leaving the responder to guess which action is safe.

Retention and deletion are a state machine, not a storage setting

Keep the deletion decision in application state with an auditable DeleteAt value, then make the object lifecycle follow that decision. A restore can be allowed only while the snapshot is still retained. Once it crosses the deletion boundary, the service should reject new links even if an old object reference still exists in an asynchronous cleanup path.

There is a real trade-off here. Immediate deletion after a requested restore reduces residual-data exposure, but it can strand a slow or interrupted download. A longer retention window gives operations room to reissue authorization, yet increases the set of snapshots that must be tracked for policy and deletion. The useful decision rule is explicit: choose the retention window from the recovery obligation, and choose the much shorter URL lifetime from transfer conditions.

This pattern is a poor fit for a client that needs live, per-request transformation or a transfer that must be revoked immediately after the first byte. A short-lived presigned credential may be reusable before expiry, and the storage service usually cannot report business-level restore success. In those cases, a server-mediated delivery boundary or a purpose-built transfer workflow gives more control, at the cost of operating that path and carrying its bandwidth and failure modes.

Deletion needs its own failure handling. Mark a snapshot as pending deletion before removing its object, make the delete worker idempotent, and retain only the audit fields required to show what was removed. If a worker retries, the desired result is still one deleted object and one terminal record. Do not turn an already-deleted object into an incident by treating a repeated delete as a new failure.

Browser behavior is another boundary

CORS controls what a browser may expose to JavaScript across origins; it does not authorize access to a private object. When a web application downloads from a different origin, allow only the required origins and methods, and test the preflight behavior where the browser needs it. A permissive CORS response cannot repair an overly broad signed URL.

The test matrix should be short but deliberate:

  • A correct-tenant request for a retained snapshot receives a link for that snapshot only.
  • A cross-tenant request, malformed snapshot ID, expired link, and deleted snapshot are rejected.
  • Repeating a restore request does not create another snapshot; repeating a link request is traceable as another issuance.
  • A cross-origin browser path is tested with its actual allowed origin and method.

This is operational work, not ceremony. The rejection cases show whether the ownership boundary still holds when a support request, queue retry, or browser retry takes an unexpected route.

Tune the alert for the cost of a false positive

An alert on every issued-but-uncompleted link turns ordinary behavior into a page: a user may close a laptop, switch networks, or decide not to download. Repeated noise teaches responders that a genuine unavailable backup looks routine.

Page only for sustained tenant impact or for a stage that is clearly failed. For weaker evidence, use a ticket or dashboard tied to the promised restore window, then compare the issued, started, and completed populations before changing a threshold. This preserves the signal that matters: an authorized tenant cannot obtain the selected retained snapshot when the recovery workflow says it should be available.

The design stays intentionally plain: private objects, tenant-scoped selection, a narrow expiring credential, redacted telemetry, and an explicit deletion state. It has limits, but it makes each limit visible to the runbook before the next restore page arrives.

Further reading

References:

Top comments (0)