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.

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 pathSuggested TTLReasoning
Button the user just clicked300 sThe click happens now; a long window only widens exposure
Emailed “your export is ready” link24 hMail delays and time zones are real; a day covers both
Link embedded in a signed webhook to a partner3600 sLong enough for a retry chain, short enough to be uninteresting later
Anything you’re tempted to set to 7 daysDon’tA 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.pdf is fine only because usr_4471 is opaque; exports/42/january.pdf is 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 v3Infrai
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
ResponseContentDispositionresponse_disposition in the presign body
HeadObjectCommandGET /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.

References

Browse more storage developer guides