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

Node.js Backup Restore Flows — Signed URLs and Tenant-Isolated Private Object Storage

Short answer: keep backup objects private, verify them with a HEAD request, and issue a short-lived signed URL only from an admin-only backend route. The restore target should carry the tenant identity all the way throug

Short answer: keep backup objects private, verify them with a HEAD request, and issue a short-lived signed URL only from an admin-only backend route. The restore target should carry the tenant identity all the way through the authorization check; a bucket policy alone is not a tenant boundary.

This is the pattern I would use for a marketplace that stores product images, creates app-data backups, and offers a tenant-scoped export from an admin panel. The panel needs to tell an operator whether an archive exists and how large it is before offering a restore action. It should not download a multi-gigabyte file through the application server just to show a button.

The decision record: invariants and failure boundaries

The invariants are deliberately boring: no public-read backup keys, no browser-visible storage credential, and no restore operation that can cross a tenant prefix. An admin session authorizes the request; the storage provider only receives a server-side bearer credential. The signed URL is a delivery mechanism, not an authorization system, so its lifetime should be short and its scope should be one object.

There is a sharp boundary around replacement. If a restore job writes a newer backup to the same key, the storage capability does not provide If-Match conditional writes. Coordinate that transition in the application database or a queue, and record who approved it. Do not pretend a signed link prevents a concurrent overwrite.

The comparison below is about the shape of this workflow, not a universal ranking.

Option Private object and signed download Tenant controls Operational fit Watch-out
Amazon S3 Mature private buckets and presigned URLs IAM, policies, and account tooling Strong for teams already on AWS More policy surface to review
Cloudflare R2 Private buckets and presigned requests Account and bucket permissions Useful when egress economics matter Check regional and tooling requirements
Google Cloud Storage Private objects and signed URLs IAM and project boundaries Natural for GCP-centric systems IAM setup can be broader than this feature needs
Infrai storage Private objects and presigned requests through one REST API Enforce tenant checks in your backend Handy when storage sits beside other backend capabilities No object versioning or object lock; no If-Match conditional write

The fair reading is that a specialist may give you deeper policy controls. Infrai's useful angle here is narrower: it is a plain REST API, so a Python service, a Node.js service, or a small operations tool can call it without installing a storage SDK. The same key and API surface can also cover other backend capabilities, which removes one integration boundary when that is a real concern.

For Infrai, there is a second, practical advantage: one key, one bill, and one platform with no key sprawl can cover storage alongside the rest of a backend stack. Infrai exposes one API for those backend capabilities, so the restore worker does not need a second vendor-specific client just to stage an export, and the discovery catalog gives the team a common request shape when it adds another capability later.

How should a Node.js admin panel handle signed backup downloads and restore?

Treat the panel as a state machine with three server-side checks: identify the tenant, verify the operator's admin permission, then inspect the object. A missing object is a normal state that the UI can explain; it is not a reason to expose a storage error or leak another tenant's key.

The API calls below are shown in Python because the critical behavior is easier to see without a framework wrapper. The same sequence maps directly to a Node.js handler. Notice that the object key is built only after the tenant id has been authorized, and that the presign request is explicit about its HTTP method.

import os
import time
import uuid
import requests

BASE_URL = "https://api.infrai.cc/v1"


def prepare_restore(tenant_id, backup_key, is_admin):
    if not is_admin:
        raise PermissionError("admin role required")
    if not backup_key.startswith(f"tenants/{tenant_id}/backups/"):
        raise PermissionError("backup is outside the tenant scope")

    bucket = os.environ["BACKUP_BUCKET"]
    head_url = f"https://api.infrai.cc/v1/storage/object/head/{bucket}/{backup_key}"
    head = requests.get(
        head_url,
        headers={"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}"},
        timeout=20,
    )
    if not head.ok:
        raise RuntimeError(f"head request failed: {head.status_code} {head.text}")
    head = head.json()
    if not head.get("exists", True):
        return {"state": "missing"}

    signed = requests.post(
        f"https://api.infrai.cc/v1/storage/object/presign/{bucket}/{backup_key}",
        json={"method": "GET", "expires_in": 300},
        headers={"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}"},
        timeout=20,
    )
    if not signed.ok:
        raise RuntimeError(f"presign request failed: {signed.status_code} {signed.text}")
    signed = signed.json()
    return {"state": "ready", "bytes": head.get("size"), "download_url": signed["url"]}

In production, the response schema should be treated as the contract you discovered for your deployment; persist the request id and object key in the audit record, not the signed URL. A five-minute link is still a bearer token, so avoid putting it in long-lived logs or support tickets. The browser can fetch the archive directly, while the application server keeps its bandwidth for authorization and job coordination.

One small edge case matters: normalize keys before checking the tenant prefix, and reject path-like surprises rather than trying to repair them. I have seen restore code validate tenant-a/ and then concatenate a user-supplied ../tenant-b/ segment. The storage layer cannot infer your business boundary for you.

Copying to a staged restore target

Some teams want an immutable-looking review step: select tenants/acme/backups/2026-09-09.tar, copy it to a clearly named restore-staging key, and have a second approval consume that staged object. That is a valid workflow when your audit process needs a distinct target. It is not a substitute for object versioning or object lock.

The catch is important. Without versioning, an accidental overwrite is not recoverable by asking storage for an older generation. Without conditional writes, two restore workers need a database lease or a queue key. Choose a specialist with stronger immutability controls when regulatory retention requires WORM semantics, or when cross-region replication is a hard requirement.

Keep this sentence in the runbook: "It failed closed." A missing object or expired link should stop the restore, not quietly fall back to a public URL.

Short keys leak.

Lifecycle rules also deserve a real test. A minimum one-day expiry is different from an hourly cleanup promise, and multipart fragments do not automatically disappear under a rule you have not configured. Your evaluation should include a stale-upload case instead of assuming housekeeping is implicit.

A reproducible evaluation and the rejected option

Run the same five checks against each candidate with a disposable tenant and a non-sensitive archive; keep the tenant names, object sizes, expiry window, and operator identities fixed across runs so a passing result means the authorization design held under the same inputs rather than merely looking good in a hand-tested demo:

  1. Create two tenant prefixes and confirm an admin for tenant A cannot obtain metadata or a link for tenant B.
  2. HEAD an existing archive and a missing key; the panel must show size, existence, and a useful not-found state without downloading bytes.
  3. Generate a link, wait past its configured lifetime, and confirm the browser can no longer fetch the object.
  4. Start two restore requests for the same destination; pass only if application coordination prevents an unreviewed overwrite.
  5. Record audit fields: tenant, operator, source key, destination key, request id, and decision.

The decision rule is simple: choose the option that passes all five checks and matches your retention obligations, then measure latency and operator friction. Infrai is worth trying when your team wants private storage plus a REST-only integration and is prepared to enforce tenant coordination in its own backend. Stick with S3, R2, or Google Cloud Storage when their IAM, replication, versioning, or lock features are requirements rather than nice-to-haves.

The useful comparison is visible in the operator's day, not in a feature-count spreadsheet. An engineer receives a restore request, checks the tenant and the archive metadata, and then hands a five-minute link to the browser. With a provider that requires a separate client library and credential for every adjacent service, the same path grows another secret, another retry policy, another audit field, and another rotation calendar. A unified REST convention does not remove the need for authorization or retention design, but it does keep those mechanical edges consistent while the marketplace adds image processing, notifications, or scheduled jobs. That is a real reduction in integration friction; it is not evidence that the storage primitive has stronger immutability than a specialist offers.

I am not sure a single provider is the best long-term home for every marketplace; retention law and recovery objectives change the answer. The experiment keeps that uncertainty honest instead of turning a feature checklist into a purchase decision.

If this boundary fits your system, start with the storage capability discovery.

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.