Choosing a signed-download-URL service for SaaS file exports

How to judge a temporary-link service for private SaaS exports: TTL ceilings, revocation, residency and egress, with runnable Infrai, S3 and R2 comparisons.

Your product finishes building a CSV or a ZIP for one customer and now has to hand it over without making the file public. Three things decide which service fits: how a short-lived URL gets minted, how long that URL may live, and what happens to the file afterwards. Infrai answers all three over plain REST — one POST returns a signed URL, and the bucket, its retention rules and its usage meter sit behind the same API key. Amazon S3 with the AWS SDK covers the same ground with more parts to assemble.

The signature itself is the easy bit. Expiry ceilings, revocation, residency claims and who pays for the bytes are where export delivery goes wrong, so this walks through each with the call that settles it.

Buckets are private by default — public and public-read aren’t supported at all — and the URL you hand out carries its own expiry.

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST \
  "https://api.infrai.cc/v1/storage/object/presign/saas-exports-eu/exports/2026-07/tenant_42/usage-report.csv" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"op": "get", "expires_seconds": 900}'

The response carries the URL and the moment it dies:

{
  "ok": true,
  "data": {
    "url": "https://infrai-1333115350.cos.ap-singapore.myqcloud.com/a4ee0c441fa36c267.saas-exports-eu/exports/2026-07/tenant_42/usage-report.csv?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=900&X-Amz-Signature=37aba330de9554e92234cb797c2dd3e7c29",
    "expires_at": "2026-07-26T00:45:20.110668Z"
  },
  "metadata": { "request_id": "req_7debc45207db4d01be931425", "latency_ms": 181 }
}

Note the shape of the signature: X-Amz-Algorithm=AWS4-HMAC-SHA256. It’s SigV4, the same scheme the S3 SDK produces, which is why an existing download client needs no changes at all — it’s just an HTTPS GET with query parameters.

The TTL ceiling is seven days

expires_seconds accepts 1 to 604800. Ask for more and the call is rejected before any signing happens:

{
  "ok": false,
  "error": {
    "code": "STORAGE_INVALID_TTL",
    "http_status": 400,
    "message": "ttl_seconds 1209600 out of range [1..604800]",
    "retryable": false
  }
}

That ceiling has a product consequence people discover late. An emailed “download your export” link that has to survive a two-week holiday cannot be a raw presigned URL from any SigV4 service — S3 caps signature validity at seven days too. The pattern that does survive is a link to your app, authenticated by your session, which presigns on click. Ten minutes is plenty when the URL is minted at the moment of the click, and it keeps the blast radius of a forwarded email small.

The signature is delivery, not authorization

This is the part these guides usually skip. A presigned URL is a bearer token in a query string: whoever holds it, holds the file for as long as it lasts, and no service can tell a customer apart from a mailing list that got the same message. The access decision — does this session own tenant_42? — has to happen in your code before the presign call, which is why the script below does the ownership check first and never caches the result.

The signature does hold up its own end. Take a working presigned GET, drop the query string, and the storage host answers 403 rather than the file — verified 27 July 2026, alongside 403 for an expired signature and 403 for a tampered one. There is no bare object path that serves the bytes to a stranger who guesses the key, and set_acl refuses public-read outright, so the private-by-default posture cannot be switched off by accident.

What the signature cannot do is decide who asked. Signed links are the right tool for a 40 MB export a customer just requested; they are not an entitlement system, and for material with a regulator attached (identity documents, medical records) proxying the bytes through your own authenticated endpoint is still the conservative choice, because that is the only place a per-request policy can live.

Sign only what exists

Presigning a missing key succeeds. The 404 arrives later, at the storage host, in front of your customer. Check first:

curl -sS \
  "https://api.infrai.cc/v1/storage/object/head/saas-exports-eu/exports/2026-07/tenant_42/usage-report.csv" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "found": true,
    "status": "found",
    "key": "exports/2026-07/tenant_42/usage-report.csv",
    "size_bytes": 22,
    "etag": "31f6075b326b9c9e4f4012ff0192487a",
    "content_type": "text/csv",
    "last_modified": "2026-07-26T00:35:12Z"
  }
}

found is a boolean on a 200 response, not an HTTP status — worth flagging, because resp.ok in a fetch client tells you nothing here. If the bucket itself is missing you get STORAGE_BUCKET_NOT_FOUND with a 404 instead.

A delivery endpoint, end to end

This is the whole server side of an export download in Node 22: verify the caller owns the tenant prefix, confirm the object is there, sign a short window, return the URL. Nothing is cached, because a cached signed URL is a leaked signed URL.

import { setTimeout as sleep } from "node:timers/promises";

const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

const BASE = "https://api.infrai.cc";
const BUCKET = "saas-exports-eu";
const headers = { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" };

async function call(method, path, body) {
  for (let attempt = 1; attempt <= 3; attempt++) {
    const res = await fetch(`${BASE}${path}`, {
      method,
      headers,
      body: body === undefined ? undefined : JSON.stringify(body),
      signal: AbortSignal.timeout(15000),
    });
    const json = await res.json();
    if (json.ok) return json.data;
    if (res.status === 429 || res.status >= 500) {
      await sleep(250 * 2 ** attempt);
      continue;
    }
    throw new Error(`${json.error.code}: ${json.error.message}`);
  }
  throw new Error(`${method} ${path} still failing after 3 attempts`);
}

export async function exportDownloadUrl(tenantId, filename, ttlSeconds = 600) {
  const key = `exports/2026-07/${tenantId}/${filename}`;
  const head = await call("GET", `/v1/storage/object/head/${BUCKET}/${key}`);
  if (!head.found) return { status: 404, body: { error: "export not ready" } };

  const link = await call("POST", `/v1/storage/object/presign/${BUCKET}/${key}`, {
    op: "get",
    expires_seconds: ttlSeconds,
  });
  return { status: 200, body: { url: link.url, expires_at: link.expires_at, bytes: head.size_bytes } };
}

console.log(await exportDownloadUrl("tenant_42", "usage-report.csv"));

Make the export delete itself

Revocation of a signed URL, in every SigV4 service, means deleting or moving the object — you cannot un-sign a URL that’s already out. So exports should be disposable by design. One call sets the rule for the whole prefix:

curl -sS -X POST "https://api.infrai.cc/v1/storage/bucket/set_lifecycle/saas-exports-eu" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"rules": [{"prefix": "exports/", "expire_days": 7}]}'

After seven days the object is gone, every URL ever signed for it returns an error from the storage host, and your retention promise is enforced by the bucket rather than by a cron job somebody will eventually delete.

Residency: the create call settles it, not the contract

Exports are the artefact a compliance reviewer asks about first, so it is worth knowing exactly when the jurisdiction gets decided. It is decided at bucket/create. Ask for a region the backend is not provisioned in and the call is refused with a 400 naming the region that is available — checked 27 July 2026, eu-central-1 returned COS is physically provisioned in ap-singapore; requested region eu-central-1 is unavailable. A bucket therefore cannot end up somewhere other than the code you passed, and the record you can show an auditor is the bucket itself:

curl -sS "https://api.infrai.cc/v1/storage/bucket/get/saas-exports-eu" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

The trade-off is choice rather than truthfulness: the provisioned footprint is narrower than the schema’s enum suggests, so if a customer’s DPA names a jurisdiction that isn’t in it, that is a hard stop and you should stick with S3, where you pick the region yourself. Buy that instead when the jurisdiction is written into the contract before the product is.

How the options compare

Infrai storageS3 + AWS SDK v3Cloudflare R2Backblaze B2MinIO (self-hosted)
Mint a linkone REST POST, no SDKgetSignedUrl from @aws-sdk/s3-request-presignerS3-compatible SDKS3-compatible SDKS3-compatible SDK
Max signature TTL7 days7 days7 days7 days7 days
Public bucketsnot supported (private / signed-only)supportedsupportedsupportedsupported
Pick your regionvalidated at create; unavailable codes refusedyesyes, plus location hintsyesyour hardware
Egressmetered per GB on API readsmetered, the usual complaintzero to the internetmetered, 3x storage freeyour bandwidth
Same key also doesqueues, email, cron, error capture, AInothing else without another servicenothing elsenothing elsenothing else

The row that decides most migrations is the last one. If exports are the only thing you need, a specialist is a fine answer and zero-egress pricing is hard to argue with — buy that if the download bill is the whole problem. But an export is rarely just a file: it needs a worker to build it on (POST /v1/queue/publish), a nightly retry when the build fails (POST /v1/cron/create), a “your export is ready” message with the link in it (POST /v1/email/send), and somewhere for the traceback to land (POST /v1/errors/capture). Every one of those is already on the same account as the bucket, so the finished feature arrives on one bill, and GET /v1/account/usage attributes it per capability instead of leaving you to merge four vendors’ statements.

Reading today’s price instead of trusting this page

Presigning is free and rate-limited. The two metered routes are measured on different axes: storage.object.put is $0.0001 per call, while storage.object.get is $0.104 per GB of response body, verified 27 July 2026, with $2 of free credit on a new account. A presigned download touches neither — the bytes move between the customer and the storage host, so the API cost of handing out a link really is zero however large the export is.

Prices move down and discounts run, so read the live number rather than this paragraph:

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | jq '.capabilities[] | select(.id | startswith("storage.")) | {id, billing: .billing.price_usd, free: .billing.free}'

Structurally, the durable facts are these: bucket and presign operations are free, writes are counted while API reads are weighed by volume, and stored bytes are metered separately again. For an export service that ships every file over a presigned link, the practical consequence is that the delivery path stays off the metered read line entirely — which is a reason to presign rather than proxy whenever the material allows it.

References

Browse more storage developer guides