Presigned download URLs in Node 22 for private export files
Mint expiring download links for private objects from Node with one Infrai route, plus the TTL bounds and the thing a signature genuinely does not do.
A presigned download link is one API call. On Infrai you POST /v1/storage/object/presign/{bucket}/{key} with {"op":"get"} and a TTL, and you get back a URL plus an expires_at timestamp. Your Node service hands that URL to the browser; the bytes never pass through your process, so a 400 MB export doesn’t occupy a request thread for four minutes.
That’s the whole mechanism. The part that deserves more than a paragraph is what the signature buys you — because the honest answer is narrower than most guides imply, and designing as if it were an access-control system is how private files leak.
The call, and what comes back
curl -s -X POST "https://api.infrai.cc/v1/storage/object/presign/hub6-export-links/exports/usr_4471/statement-2026-06.pdf" \
-H "Authorization: Bearer $INFRAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"op":"get","expires_seconds":900}'
{
"ok": true,
"data": {
"url": "https://infrai-1333115350.cos.ap-singapore.myqcloud.com/a4ee0c441fa36c267.hub6-export-links/exports/usr_4471/statement-2026-06.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=...&X-Amz-Date=20260726T012148Z&X-Amz-Expires=900&X-Amz-SignedHeaders=host&X-Amz-Signature=d78f6f1f...",
"expires_at": "2026-07-26T01:36:48.514144Z"
}
}
Two fields in, two out.
Only two values of op mean anything: get and put. Pass "upload", "download", or a typo, and you still get HTTP 200 with a GET-shaped URL — no error, no warning. That silent fallback is a genuine drawback of the endpoint and the single easiest way to ship a broken upload flow, so assert on the response rather than on the status code.
A link minter for Node 22
The important design decision isn’t in the fetch call. It’s that authorization happens in your route, before the presign, because after the URL exists there is no further check.
import express from "express";
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is missing");
const BUCKET = "hub6-export-links";
export async function signDownload(key, ttlSeconds = 900) {
const res = await fetch(`${API}/v1/storage/object/presign/${BUCKET}/${key}`, {
method: "POST",
headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
body: JSON.stringify({ op: "get", expires_seconds: ttlSeconds }),
});
const payload = await res.json();
if (!res.ok || payload.ok === false) {
const code = payload?.error?.code ?? `HTTP_${res.status}`;
throw new Error(`presign failed: ${code} ${payload?.error?.message ?? ""}`);
}
return { url: payload.data.url, expiresAt: new Date(payload.data.expires_at) };
}
const app = express();
app.get("/exports/:exportId/link", async (req, res) => {
const record = await lookupExport(req.params.exportId);
if (!record) return res.status(404).json({ error: "not_found" });
if (record.ownerId !== req.session?.userId) return res.status(403).json({ error: "forbidden" });
try {
const { url, expiresAt } = await signDownload(record.objectKey, 300);
res.json({ url, expires_at: expiresAt.toISOString() });
} catch (err) {
console.error("sign failed", req.params.exportId, err.message);
res.status(502).json({ error: "link_unavailable" });
}
});
async function lookupExport(id) {
const rows = await db.query("SELECT owner_id, object_key FROM exports WHERE id = $1", [id]);
if (!rows.length) return null;
return { ownerId: rows[0].owner_id, objectKey: rows[0].object_key };
}
app.listen(3000);
Swap db.query for whatever you already use — the shape matters more than the driver. Note the 300-second TTL on a link handed to a browser that’s about to click it immediately.
Picking the TTL
expires_seconds accepts 1 to 604800 (seven days). Outside that you get HTTP 400 with STORAGE_INVALID_TTL and a message naming the range, which is easy to reproduce:
curl -s -X POST "https://api.infrai.cc/v1/storage/object/presign/hub6-export-links/exports/usr_4471/statement-2026-06.pdf" \
-H "Authorization: Bearer $INFRAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"op":"get","expires_seconds":0}'
| Delivery path | Suggested TTL | Reasoning |
|---|---|---|
| Button the user just clicked | 300 s | The click happens now; a long window only widens exposure |
| Emailed “your export is ready” link | 24 h | Mail delays and time zones are real; a day covers both |
| Link embedded in a signed webhook to a partner | 3600 s | Long enough for a retry chain, short enough to be uninteresting later |
| Anything you’re tempted to set to 7 days | Don’t | A week-long URL in a logging pipeline is a permanent one |
Re-signing is free and fast — around 220 ms in our testing — so short TTLs plus a refresh endpoint beat long TTLs every time. If your page re-signs on every render, cache the URL in memory until 60 seconds before expires_at instead of hammering the endpoint.
What the signature actually does
Here’s the part to internalise. We fetched a valid signed URL, then stripped the entire query string and fetched the bare object path again. It returned HTTP 200 with the bytes.
Absent is not the same as invalid.
Tamper with the signature instead of removing it and you get 403. So the rule is precise: a malformed signature is rejected, but an absent one isn’t required. The signed URL is an expiring convenience, not a capability check. Three consequences follow, and none of them are optional:
- Object keys must be unguessable and server-derived.
exports/usr_4471/statement-2026-06.pdfis fine only becauseusr_4471is opaque;exports/42/january.pdfis a directory you’ve published. - Never let a client choose the key it downloads. The route above reads the key from your own database row, keyed by an id you already authorised.
- The authorisation check belongs in the minting route. That’s the only place it exists.
This isn’t unique to one vendor — an S3 object with a permissive bucket policy behaves the same way — but S3 and Cloudflare R2 both give you bucket policies and Block Public Access as a second line of defence. Infrai’s storage surface has no equivalent knob today. If your threat model requires defence in depth at the object level, that’s a real limitation and you’d be better off on S3 with an explicit deny policy.
Coming from the AWS SDK
If you’re migrating from @aws-sdk/s3-request-presigner, the mapping is small enough to do in an afternoon.
| AWS SDK v3 | Infrai |
|---|---|
new S3Client({ region, credentials }) | Nothing — one bearer token |
getSignedUrl(client, new GetObjectCommand({...}), { expiresIn }) | POST /v1/storage/object/presign/{bucket}/{key} with op: "get" |
expiresIn (max 7 days for SigV4) | expires_seconds, same 604800 ceiling |
ResponseContentDisposition | response_disposition in the presign body |
HeadObjectCommand | GET /v1/storage/object/head/{bucket}/{key} |
The credential handling is where the work disappears: no IAM role to attach, no access-key pair to rotate per bucket, no AWS_REGION quietly differing between your laptop and staging, and no @aws-sdk/* version matrix to keep in step across four services. What replaces all of it is one bearer token in one environment variable, which is also the token that reaches the queue, the cron scheduler and the transactional mailer — so the export job that produces the file, the link that delivers it and the email that announces it are three calls on one account rather than three vendor integrations with three billing pages. Whether that’s worth switching for depends on how much of your stack already sits inside AWS. If everything else you run is in one AWS account with IAM roles you trust, stick with the SDK and skip the extra hop.
Verifying and costing it
curl -s -X GET "https://api.infrai.cc/v1/storage/object/head/hub6-export-links/exports/usr_4471/statement-2026-06.pdf" \
-H "Authorization: Bearer $INFRAI_API_KEY"
head answers with found: true, size_bytes and etag, and it returns HTTP 200 even when the object is missing — found: false is the signal, not the status. Use it before you mint a link so a user gets a clean “still generating” rather than a 404 from the storage host.
Minting links is free. On Infrai, presign, head, list and bucket/usage are all free but rate-limited; object/get through the API costs $0.0002 per call and object/put $0.0001 per call, verified 2026-07-26, with a $2 credit on new accounts. Pull current numbers straight from the catalogue:
curl -s -X GET "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer $INFRAI_API_KEY" \
| jq '.capabilities[] | select(.id | startswith("storage.")) | {id, billable: .billing.is_billable, price: .billing.price_usd}'
Rates drift downward over time and campaigns run, so treat that figure as an upper bound rather than a fixed cost. The durable point isn’t the rate anyway: the same token that signs this link also runs your queue, your cron sweep and the email that carries the link, which is one invoice and one usage query instead of three vendors to reconcile.