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.

For genuinely sensitive material (identity documents, medical records, anything with a regulator attached) the conservative pattern is still to proxy the bytes through your own authenticated endpoint, and to verify for yourself how the object host answers an unsigned request before you rely on the signature as a boundary. Signed links are the right tool for a 40 MB export a customer just asked for; they’re not an entitlement system.

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: read what the region field actually gave you

This one deserves a blunt paragraph. Requesting region: "eu-central-1" records that region on the bucket, and in our testing on 2026-07-26 the presigned host we got back still pointed at an ap-singapore backend. Check yours before you write a residency clause into a contract:

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

If a customer’s DPA names a specific jurisdiction, the honest answer is to verify the returned host per bucket, or to stick with S3 or Cloudflare R2 where you pick the region and location hint yourself. That’s a real limitation, and it’s the one thing on this page a compliance reviewer will care about most.

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 regionrequested, verify the hostyesyes, plus location hintsyesyour hardware
Egressmeteredmetered, 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 R2’s zero egress is hard to argue with. If the export job also needs a queue to run on, an email when it’s ready and somewhere to capture the failure, those are three more accounts and three more invoices — or they’re already on the key you just used.

Reading today’s price instead of trusting this page

Presigning is free and rate-limited. Reads and writes through the API are metered per call: storage.object.get at $0.0002 and storage.object.put at $0.0001, verified 2026-07-26, with $2 of free credit on a new account. A presigned download doesn’t touch either — the bytes move between the client and the storage host, so the API cost of handing out a link is zero.

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, per-object API reads cost roughly twice per-object API writes, and stored bytes are metered separately from calls.

References

Browse more storage developer guides