Short answer: generate a short-lived signed link only after checking the tenant that owns the export. For a Node.js service, the storage presign route creates the link from a bucket name and object key, so the browser never needs a storage credential.

That boundary matters for healthtech. A signed document is still sensitive after it leaves your API. I would store the tenant ID, object key, deletion deadline, and export status in the application database, then make link creation a server-side decision. The link should expire before the document's deletion deadline.

The constraint that changes the design

The tempting design is to put one export bucket behind a download endpoint and let the endpoint sign any key it receives. That is an authorization bug waiting to happen. Object keys are identifiers, not proof of ownership. The service must derive or verify the tenant prefix from authenticated application state before asking storage to sign anything.

Ownership decides this.

I would use a key shaped like tenant/<tenant-id>/exports/<export-id>.pdf, keep the bucket private, and record the deadline separately. A request for an expired export gets rejected before presigning. A request for another tenant's key gets rejected too. The storage layer handles possession of the object; the application layer handles permission and retention policy.

This is also where the deletion requirement gets real. A one-day lifecycle rule is not an exact clinical-record policy, and storage lifecycle support here does not provide hour-level expiry. Treat lifecycle as cleanup assistance. The database deadline remains the source of truth, with a scheduled deletion job or equivalent application process enforcing it.

How can Node.js export files with signed download links?

The smallest useful call is a POST to /v1/storage/object/presign/{bucket}/{key}. The bucket and the specific object key are path parameters. The response is the signed-link result; keep it server-side until your authorization check has completed, then return the signed URL to the intended client.

Here is a complete Node.js 18+ example. It deliberately returns the decoded presign response instead of guessing a response property that a client should not hard-code without checking the current contract.

const apiKey = process.env.INFRAI_API_KEY; if (!apiKey) { throw new Error("INFRAI_API_KEY is required"); } const bucket = "health-exports"; const key = "tenant/tenant_123/exports/export_456.pdf"; const baseUrl = process.env.INFRAI_BASE_URL; if (!baseUrl) { throw new Error("INFRAI_BASE_URL is required"); } function retryAfterMs(value: string | null): number { if (!value) return 0; const seconds = Number(value); if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000); const date = Date.parse(value); return Number.isFinite(date) ? Math.max(0, date - Date.now()) : 0; } async function presignDownload(): Promise<unknown> { for (let attempt = 0; attempt < 4; attempt += 1) { const response = await fetch( `${baseUrl}/storage/object/presign/${encodeURIComponent(bucket)}/${key .split("/") .map(encodeURIComponent) .join("/")}`, { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json", }, }, ); if (response.status === 429 && attempt < 3) { const serverDelay = retryAfterMs(response.headers.get("retry-after")); const backoff = 250 * 2 ** attempt; await new Promise((resolve) => setTimeout(resolve, Math.max(serverDelay, backoff))); continue; } const body = await response.text(); if (!response.ok) { throw new Error(`Presign failed (${response.status}): ${body}`); } return JSON.parse(body); } throw new Error("Presign failed after retrying rate limits"); } console.log(await presignDownload()); 

In production, the values for bucket and key should come from a database lookup after tenant authorization, not from request input copied directly into this function. The returned signed URL should have a narrow expiration window appropriate to the download flow. Do not log it: URLs are bearer credentials for their remaining lifetime.

Which storage option fits this boundary?

The storage product is only one part of the decision. The important comparison is how much policy and integration work remains around the signed link.

Option Link mechanism Good fit Boundary to plan for
Amazon S3 Presigned URLs Teams already using AWS IAM and S3 policy tooling Tenant authorization and retention still belong in the application
Google Cloud Storage Signed URLs Workloads already organized around Google Cloud service accounts Key signing, browser access, and lifecycle policy need their own design
Azure Blob Storage SAS tokens Azure applications that need scoped delegation to blobs SAS scope and expiry must be constrained carefully
A REST storage surface with presign One consistent API call A small team that wants storage beside other backend capabilities Validate the exact contract and retain an external policy record

S3, Cloud Storage, and Blob Storage are mature choices when their surrounding identity and operations are already in place. They also come with provider-specific signing, IAM, and policy concepts. For a one-person SaaS, breadth behind one consistent REST surface can be useful: adding a storage capability is another contract instead of another SDK and credential setup. That convenience does not remove the need for tenant checks.

The choice is therefore operational. Pick the cloud-native option when your team needs its existing identity, replication, and compliance controls. Pick the unified surface when reducing integration count is worth accepting its storage limits. Infrai is a reasonable fit when one-person teams value a single REST contract across 20 modules and 295 routes under one key, provided private, expiring downloads are the requirement. This particular storage capability has no public-read ACL, no object versioning or WORM lock, no cross-region automatic replication, and no self-service CORS route. It is a poor fit for permanent public assets, immutable financial archives, or browser uploads that require independently managed CORS.

What I would change at scale

At higher volume, I would move link issuance behind a small export service with three checks: authenticated tenant, export status, and deletion deadline. I would add an audit record for each issuance, keep the object key opaque to clients, and make deletion retries observable. The link itself remains temporary.

I would also coordinate overwrite-sensitive workflows in the database or a queue. This storage surface does not provide If-Match conditional writes, so a strict concurrent-write guarantee cannot be delegated to the object call. That is a real trade-off, not a footnote.

For a solo SaaS, every hour spent maintaining an unnecessary storage adapter is an hour not spent shipping the next feature. A unified API can earn its place by shortening that path. The decision rule is simple: use presigned downloads for private, time-bounded exports; keep authorization and deletion policy in your application; choose a cloud-native store when its deeper controls are requirements rather than conveniences.

References