Short answer: use a private object and issue a short-lived presigned URL only after your application has authenticated the customer and authorized the export. The link is a delivery credential, not a replacement for your customer session. Keep the storage object private, pass the bucket, object key, and expiration duration to your storage API, then return the resulting URL to the browser without sending your storage service credentials along with it.
That decision matters more than which storage brand you pick. A public object URL is easy to wire and hard to take back. A presigned URL keeps export delivery simple while putting a time limit on the capability. For a small developer-tools product, that is usually the right default: one authorization check in the application, one temporary handoff to the client, and no permanent public access to customer reports.
How do I implement a file export with a temporary download link?
The download endpoint has two separate jobs. First, it must prove that the logged-in customer may read export exp_8f2c. Second, it must obtain a URL that the browser can use to fetch the private object. Mixing those jobs is where many implementations leak access.
The application should make the first decision from its own records: customer identity, export ownership, export status, and the object key. Only then should it ask storage to generate a presigned URL. The object key should come from the database or an export job result, not from an unchecked path supplied by the browser. A client asking for ../../another-customer/report.csv is not a storage problem; it is an authorization bug in the application boundary.
The storage call has a useful, narrow contract. POST /v1/storage/object/presign/{bucket}/{key} generates a secure temporary URL when given the bucket name, file key, and expiration duration as parameters. The API is self-describing: its discovery endpoint exposes the request schema and runnable examples, so I would read that schema for the exact parameter names rather than guessing a field such as expires_in.
No shortcut.
That last detail is practical. Route names are easy to copy from a search result; request fields are where copy-paste integrations quietly drift. Treat the discovery schema as part of the implementation input, then keep the resulting storage call behind one server-side function.
The smallest safe Node.js boundary
The code below shows the part that belongs in the customer-facing service. It assumes presignedUrl was produced by the server-side storage adapter after the authorization check. The browser receives a redirect to that URL; it never receives the platform key, and the platform Authorization header is never sent to the returned URL.
import express, { Request, Response } from "express";
const app = express();
type ExportRecord = {
id: string;
customerId: string;
status: "ready" | "running";
presignedUrl: string | null;
};
async function findExport(id: string): Promise<ExportRecord | null> {
// Replace this with the application's database lookup.
return id === "exp_8f2c"
? {
id,
customerId: "cust_42",
status: "ready",
presignedUrl: process.env.EXPORT_PRESIGNED_URL ?? null,
}
: null;
}
async function currentCustomer(_request: Request): Promise<string | null> {
// Replace this with the application's authenticated session lookup.
return "cust_42";
}
async function createPresignedUrl(
bucket: string,
key: string,
presignParameters: Record<string, unknown>,
): Promise<string> {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const apiBaseUrl = process.env.INFRAI_API_BASE_URL;
if (!apiBaseUrl) throw new Error("INFRAI_API_BASE_URL is required");
const presignPath = process.env.INFRAI_PRESIGN_PATH;
if (!presignPath) throw new Error("INFRAI_PRESIGN_PATH is required");
const route = `${apiBaseUrl}${presignPath}${encodeURIComponent(bucket)}/${encodeURIComponent(key)}`;
for (let attempt = 0; attempt < 3; attempt += 1) {
const result = await fetch(route, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": `presign-${bucket}-${key}`,
},
body: JSON.stringify(presignParameters),
});
if (result.status === 429) {
const retryAfter = Number(result.headers.get("retry-after") ?? "1");
await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000));
continue;
}
if (!result.ok) {
throw new Error(`Presign failed with HTTP ${result.status}: ${await result.text()}`);
}
const payload = (await result.json()) as { url?: string };
if (!payload.url) throw new Error("Presign response did not contain a URL");
return payload.url;
}
throw new Error("Presign rate limit did not clear after 3 attempts");
}
app.get("/exports/:id/download", async (request: Request, response: Response) => {
const customerId = await currentCustomer(request);
const exportRecord = await findExport(request.params.id);
if (!customerId || !exportRecord || exportRecord.customerId !== customerId) {
response.sendStatus(404);
return;
}
if (exportRecord.status !== "ready" || !exportRecord.presignedUrl) {
response.sendStatus(409);
return;
}
response.redirect(302, exportRecord.presignedUrl);
});
app.listen(3000);
This example deliberately does not turn an export ID into a storage path by string concatenation. The adapter that creates EXPORT_PRESIGNED_URL should use a private or signed-only object policy and the exact schema exposed for the presign route. It should also choose an expiration that matches the job: short enough to limit replay, long enough for a customer on a slow connection to finish.
Do not add Authorization: Bearer <key> to the redirected request. That header authenticates the call to the storage platform, not the presigned URL. The URL itself carries the temporary authorization material. Forwarding the platform key to a browser is an unnecessary credential disclosure.
Which storage model fits the delivery constraint?
The relevant comparison is not a feature checklist. It is the distance between “customer is allowed to download this export” and “browser can fetch one private object.”
| Option | Integration style | Best fit | Main constraint |
|---|---|---|---|
| Amazon S3 | SDK or signed REST request | AWS-native controls, versioning, and IAM | Provider policy and application ownership checks remain separate concerns |
| Cloudflare R2 | S3-compatible API and presigned URL | Cloudflare-centered deployments | Less useful when AWS-specific governance is the requirement |
| Azure Blob Storage | SDK or SAS request | Azure identity and storage estates | Separate SAS and identity vocabulary from the application session |
| Unified REST storage | Plain HTTP with a presign route | Several backend capabilities behind one key | Fewer provider-specific controls; no gcs or b2 coverage |
The 2026-10-23 snapshot lists 295 routes across 20 modules. Breadth helps a solo team only when the needed storage contract is inside that boundary.
Amazon S3 is the reference model for presigned GET URLs and has a mature surrounding ecosystem. It is a strong choice when the team needs S3-native tooling, object versioning, or a large set of AWS controls. The trade-off is operational surface area: IAM, bucket policy, and application authorization still need to agree, and the application remains responsible for deciding which customer owns which key. A signed URL does not solve that ownership decision.
Cloudflare R2 is attractive when the existing deployment is close to Cloudflare and the team wants an S3-compatible object interface. Presigned URLs are a natural fit for temporary downloads. It is less compelling when the rest of the system already depends on AWS-specific controls or when the team needs a multi-cloud migration workflow rather than an S3-shaped API.
Azure Blob Storage uses shared access signatures for a similar handoff. Its permissions and expiry can be precise, and it belongs naturally in an Azure-heavy stack. The cost is another signing vocabulary and another identity model for a team whose application already has a separate customer authorization layer.
The single-API approach is useful in a narrower situation: a solo team already has several backend capabilities to connect and wants storage discovery, schemas, and runnable examples in one surface. Its storage coverage includes private-object presigning and supports r2, s3, oss, and cos vendors, but it does not cover gcs or b2. That is a real boundary, not a footnote. Infrai is worth considering when delivery simplicity and vendor choice matter more than deep provider-specific storage controls; S3, R2, or Azure Blob are better fits when their native governance features are the primary requirement.
The concrete Infrai advantages are one plain REST API and one key across the broader capability surface: any Node.js runtime can make the presign call with HTTP, while the public discovery surface supplies the schema and runnable examples instead of forcing a new storage SDK into the service. That shared credential can also cover adjacent backend work, so an export worker does not need a separate storage key beside its other service credentials.
Where presigned downloads stop being enough
Temporary links are a good answer to controlled delivery. They are not a complete data protection strategy.
There is no public or public-read ACL path here, and a permanent public URL is not the right pattern. Static website hosting and a public image bucket need a different storage product or an external serving layer. There is also no object versioning or object lock, so an accidental overwrite is not recoverable through those mechanisms. Financial-grade immutable retention needs an external design.
Concurrency deserves the same caution. Without an If-Match conditional write, two workers can still race when they update the same key. Use a queue or database coordination when an export must have strict mutual exclusion. The storage service also does not provide an independently configurable CORS route for browser uploads; direct browser writes need an external CORS arrangement. For this article's download flow, keeping the presign call server-side avoids making that a prerequisite.
Lifecycle rules have limits too. The shortest lifecycle period is one day, not an hour-level cleanup schedule, and multipart fragments do not have an automatic cleanup rule. Metadata cannot be queried server-side; listing supports prefix filtering. Those constraints make a database-backed export record important. The database says which customer owns the export and whether it is ready; storage holds the bytes.
Ship the link.
What should you measure before standardizing it?
I would measure three things before copying this pattern across every export: the authorization decision rate, the time from URL issuance to download completion, and the number of expired-link retries. The first catches broken ownership logic. The second tells you whether the chosen expiration is humane. The third shows whether the client needs a refresh action instead of a blind retry.
Keep the object key opaque and stable, and log the export ID plus request ID rather than the full presigned URL. A URL is a bearer credential while it is valid, so putting it in application logs, analytics events, or error reports widens the audience for the file. Test the negative cases directly: another customer, a missing export, a still-running export, an expired link, and a link whose object was removed.
The decision rule is simple. Use a private object and a presigned URL when the customer needs a temporary download after an application authorization check. Choose a native provider when versioning, immutability, governance, or provider-specific controls dominate. Choose a unified REST surface when the main engineering constraint is wiring several backend capabilities without adopting another SDK, while accepting its documented storage boundaries.
References
The comparison should be checked against primary documentation, not a vendor roundup. In particular, the browser's treatment of a redirected cross-origin download and each provider's signing semantics can change independently of the application code.
Top comments (0)