To generate a presigned URL for file download in S3-compatible storage with Node.js, start with the tenant boundary, not the URL. Customer-support exports have two audiences: the agent who starts an export and the customer who downloads it. A public object URL is easy to deliver, but it is the wrong default for a tenant's conversations, screenshots, or product images.
Short answer: keep the bucket private, generate a presigned URL for the exact object, and give the browser only that temporary URL. In an S3-compatible Node.js workflow, the relevant operation is POST /v1/storage/object/presign/{bucket}/{key}. Put the bucket and object key in the path, pass the required parameters, and use the returned URL before it expires.
The useful mental model is small:
- Before: the browser receives a permanent storage address and your application has to police every download.
- After: your application authorizes one tenant and one object, then hands the browser a time-limited download capability.
That choice also affects release work. When a new export path is rolled out, the storage action and its release signal should be traceable with the same application-level identity. A download link alone is not an audit plan.
How do you generate a presigned URL for file download in S3-compatible storage?
A link should answer three questions: which bucket, which object key, and how long the grant lasts. Treat the key as data, not as a string assembled from untrusted URL fragments. Validate the tenant association before requesting the presigned URL, and make the expiration short enough for the workflow without forcing an active download to race a clock.
The browser should never receive the platform API key. It also should not send that key to the returned presigned URL; the returned URL carries the authorization material expected by the storage service. Your server uses Authorization: Bearer $INFRAI_API_KEY for the API call, checks the response status, and returns only the result your client needs.
This is where “S3-compatible” can mislead. Compatibility describes a storage interface. It does not promise the same policy controls, recovery features, or browser configuration surface as every S3 implementation.
A minimal Node.js handoff
The example below keeps the download grant and the release signal in one small flow. The storage presign response becomes the payload recorded by the release observation call. Both requests use the same base URL and the same environment-provided key. In a real service, your database lookup would establish that ticket-1842 belongs to the requesting tenant before this function runs. The platform currently exposes 295 routes across 20 modules, and its documented capabilities include runnable examples in 10 languages; that breadth is useful only if the access boundary remains clear.
type PresignResponse = {
url: string;
expires_at?: string;
};
type ApiError = {
error?: {
message?: string;
};
};
const baseUrl = process.env.INFRAI_BASE_URL;
const apiKey = process.env.INFRAI_API_KEY;
if (!baseUrl || !apiKey) {
throw new Error("INFRAI_BASE_URL and INFRAI_API_KEY are required");
}
async function postJson<T>(url: string, body: unknown, idempotencyKey: string): Promise<T> {
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey
},
body: JSON.stringify(body)
});
if (response.ok) {
return (await response.json()) as T;
}
if (response.status !== 429 || attempt === 3) {
const details = (await response.json().catch(() => ({}))) as ApiError;
throw new Error(details.error?.message ?? `API request failed: ${response.status}`);
}
const retryAfter = Number(response.headers.get("Retry-After"));
const delayMs = Number.isFinite(retryAfter) ? retryAfter * 1000 : 2 ** attempt * 250;
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
throw new Error("Retry limit reached");
}
export async function createTenantExportLink(
bucket: string,
objectKey: string,
tenantId: string
): Promise<string> {
const presigned = await postJson<PresignResponse>(
`${baseUrl}/storage/object/presign/${encodeURIComponent(bucket)}/${objectKey
.split("/")
.map(encodeURIComponent)
.join("/")}`,
{
tenant_id: tenantId
},
`export-link:${tenantId}:${bucket}:${objectKey}`
);
await postJson(`${baseUrl}/logs/ingest`, {
event: "tenant_export_link_created",
tenant_id: tenantId,
object_key: objectKey,
expires_at: presigned.expires_at ?? null
}, `export-event:${tenantId}:${bucket}:${objectKey}`);
return presigned.url;
}
The code deliberately uses the route from the storage contract and a listed observability route. It does not turn the presigned URL into a second API call, and it does not print the URL into logs. In production, log an export identifier rather than sensitive query parameters. If the logging call is retried, make the event idempotent in the surrounding application so a transient failure cannot create confusing duplicate release records.
The same handoff can sit beside a database snapshot or branch operation when a release changes the export schema. A feature flag in your application can gate the new path, while the snapshot and the observation event provide the rollback context. The point is not that a storage URL can roll back a deployment. The point is that the release record can retain the exact object identity and tenant scope that the new code used.
Which storage option fits this boundary?
There is no universal winner. The access model matters more than the adjective “S3-compatible.”
Security first.
| Option | Strong fit | Boundary to check |
|---|---|---|
| Amazon S3 | Mature IAM, bucket policy, versioning, object lock, and a broad ecosystem | More policy and operational surface can be a poor match for a small service that wants one narrow integration |
| Cloudflare R2 | S3-compatible object access with Cloudflare’s edge-oriented delivery model | Confirm the exact egress, routing, and browser-delivery behavior your tenant policy requires |
| Google Cloud Storage | Strong Google Cloud IAM and object lifecycle controls | It is a separate cloud control plane; S3 interoperability does not make every Google-specific feature identical |
| Infrai storage | A self-describing REST surface for storage beside other backend capabilities behind one key; plain HTTP means no storage SDK is required | No public or public-read ACL, no object versioning or object lock, no If-Match conditional writes, and no self-service set_cors route; strict recovery, concurrency, or browser-upload requirements need an external design |
That last row is a fit for a private, signed-download workflow, not for a public image host or static website. Its breadth is the useful part: storage and other production modules share one contract, so adding a capability can mean another endpoint rather than another integration. The REST surface is also self-describing, with public discovery and schemas, so a Node.js service does not need a storage SDK just to inspect the contract. The trade is equally real. You are trusting one vendor, one bill, and one outage surface.
The cross-capability alternative is easy to describe and harder to maintain: Neon or PlanetScale for database state plus LaunchDarkly for release flags means two signups, two credential sets, and glue for correlating snapshot or branch state with flag changes and export events. That stack may be the better choice when each specialist's controls are non-negotiable. A single API key is a coordination advantage, not proof that the underlying capabilities have identical guarantees.
What will surprise you later?
First, private signed access is not the same as browser-ready direct upload. This is a limitation, not a footnote. The storage model has a cors_rules field, but there is no independent set_cors route for self-service configuration. If your support portal needs browser uploads from several origins, decide where that policy will live before choosing this path.
Second, recovery and concurrency are explicit gaps in the decision. There is no object versioning or object lock, so an accidental overwrite is not automatically recoverable. There is no If-Match conditional write, so strict mutual exclusion belongs in a queue or database coordination layer. Lifecycle expiration starts at one day, and multipart fragments do not have an automatic cleanup rule. Those details are easy to miss while the happy-path download works.
There are also scope limits: no cross-region automatic replication, no cross-cloud bulk migration tool, and vendor coverage for R2, S3, OSS, and COS rather than GCS or B2. Metadata is not server-searchable; listing supports prefix filtering. These are design boundaries, not reasons to pretend every object store behaves alike.
The decision rule
Choose presigned downloads when the resource is private, tenant-scoped, and consumed within a bounded time. Keep the bucket and ACL private. Generate the link server-side after authorization. Return the link without the platform credential, and record an event that can be correlated with the release path.
Choose a different storage design when the product needs permanent public URLs, static-site hosting, public-read ACLs, WORM retention, strict compare-and-swap writes, self-managed browser CORS, or built-in replication across regions and clouds. Those requirements are not solved by changing one path parameter.
For the customer-support export scenario, the clean boundary is therefore: private object, temporary URL, tenant check, and a release observation attached to the same workflow. The download stays simple for the browser. The authorization decision stays on the server.
Top comments (0)