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 it does two jobs at once — it gates the read, and it expires — and those two facts pull your design in different directions.

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.

op takes exactly two values, get and put. Anything else — "upload", "download", a typo you’ll stare straight past — is refused with a 400 and no URL is minted. That’s the behaviour you want from a signer: a broken upload flow fails at the call you can see in your own logs, rather than three steps later when a PUT lands on a GET-shaped URL.

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

Worth testing yourself rather than believing a paragraph. Take a valid signed URL, strip the entire query string, request the bare object path: 403. Tamper with the signature rather than removing it: 403 again. Sign correctly over a key that isn’t there: 404. The signature is the access boundary, and nothing about a private object is served without one.

What it isn’t is a per-recipient permission. It’s a bearer token with an expiry baked in — whoever holds the intact URL before expires_at is authorised, and the object can’t tell your customer from the mailing list they forwarded it to. Three consequences follow, and none of them are optional:

  • Object keys should still be unguessable and server-derived. exports/usr_4471/statement-2026-06.pdf is a fine shape; exports/42/january.pdf invites someone with a legitimate link to try 43.
  • 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 can happen — once the URL exists, nothing prompts again.

Where the surface is genuinely narrower than the incumbents: set_acl accepts private and signed-only and answers 400 STORAGE_ACL_INVALID to public-read, so there’s no object-level policy language to write, and there’s no route to kill an individual minted URL before it expires. S3 and Cloudflare R2 give you bucket policies and Block Public Access as a second layer, and CloudFront signed cookies give you revocation. If legal wants a revocation button or your threat model demands defence in depth at the object level, that’s a real limitation here and you’d be better off buying it there.

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. presign, head, list and bucket/usage are all free but rate-limited, and new accounts open with $2 of credit to try the rest on.

Two lines meter, and they don’t share a unit. PUT /v1/storage/object/put/{bucket}/{key} is $0.0001 per call — verified 2026-07-27.

GET /v1/storage/object/get/{bucket}/{key} is $0.104 per GB, billed on the bytes that come back rather than on the number of requests you made.

That asymmetry is the strongest argument for this whole pattern. A presigned download hands the bytes over from the storage host instead of round-tripping a 400 MB export through your API reads, so the size of the file stops being your API bill and starts being plain transfer. Pull current numbers from the catalogue, and read the unit beside each one — a figure that moves is a nuisance, a unit that moves rewrites your cost model:

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, unit: .billing.unit}'

Rates drift and campaigns run, so treat those figures as today’s reading rather than a fixed cost. The durable point isn’t the rate anyway. Every step around this link is already on the same key: POST /v1/cron/create schedules the nightly export, POST /v1/queue/publish runs it, POST /v1/email/send carries the URL to the user, POST /v1/errors/capture records the presign that failed at 3am, and GET /v1/account/usage tells you which tenant it cost you — no second account, no second vendor, one invoice instead of four to reconcile.

References

Browse more storage developer guides