Signed URLs for private document downloads: who pays the egress
Download-side presigned links on Infrai, and how expiry, revocation and the egress bill compare against S3, Cloudflare R2, Supabase Storage and Firebase Storage.
Handing a customer a private PDF works the same way on every object store: your backend decides whether this person may read this object, then mints a short-lived signature the client redeems straight against the bucket. Infrai signs those links through POST /v1/storage/object/presign/{bucket}/{key} with op: "get", and signing itself is free. The difference between providers isn’t the mechanism — it’s who bills you for the bytes that leave.
That’s the part worth deciding first, because a document portal is read-heavy by nature. One contract gets uploaded once and downloaded fifty times over its life, so the read path and the egress model decide the bill, not the write.
Minting a download link
One call, one object, one clock:
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST \
"https://api.infrai.cc/v1/storage/object/presign/kb-sig-0726/invoices/tenant_42/2026-07.pdf" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op":"get","expires_seconds":300}'
The response carries the redeemable URL and the moment it dies:
{
"ok": true,
"data": {
"url": "https://infrai-1333115350.cos.ap-singapore.myqcloud.com/a4ee0c441fa36c267.kb-sig-0726/invoices/tenant_42/2026-07.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=IKID64Pnt5C96uXhswpZMzQlxBJk399IKBrN%2F20260726%2Fap-singapore%2Fs3%2Faws4_request&X-Amz-Date=20260726T001704Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&X-Amz-Signature=9c5fcf64eaacde8dc3d316a548e2877",
"expires_at": "2026-07-26T00:22:04.922293Z"
}
}
It’s an AWS SigV4 URL — X-Amz-Expires=300 is the 5 minutes you asked for, and X-Amz-SignedHeaders=host means the client doesn’t have to reproduce any header to redeem it. A browser can follow it, curl can follow it, and so can anyone the recipient forwards it to. Treat it as a bearer credential with a timer.
The authorization decision happens before the signature, not inside it
The signature proves the link was issued. It says nothing about who’s holding it, which is why the tenant check belongs in your own route:
import express from "express";
import pg from "pg";
import { requireSession } from "./auth.mjs"; // your existing middleware; sets req.user
const app = express();
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const API = "https://api.infrai.cc";
const BUCKET = "kb-sig-0726";
async function lookupInvoice(id) {
const { rows } = await pool.query(
"select tenant_id, object_key from invoices where id = $1",
[id],
);
return rows[0] ?? null;
}
app.get("/invoices/:id/download", requireSession, async (req, res) => {
const invoice = await lookupInvoice(req.params.id);
if (!invoice || invoice.tenant_id !== req.user.tenantId) {
return res.status(404).end(); // 404, not 403 — don't confirm it exists
}
const url = `${API}/v1/storage/object/presign/${BUCKET}/${invoice.object_key}`;
const signed = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.INFRAI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ op: "get", expires_seconds: 120 }),
});
if (!signed.ok) {
console.error("presign failed", signed.status, await signed.text());
return res.status(502).json({ error: "download link unavailable" });
}
const { data } = await signed.json();
res.redirect(302, data.url); // bytes never touch this process
});
app.listen(3000);
Two details in there are load-bearing. The object key comes out of your own database row rather than the request, so a customer can’t walk into another tenant’s prefix by editing a path. And 120 seconds is enough for a browser to start the transfer but short enough that a link pasted into a support ticket is dead by the time anyone reads it.
Keys deserve the same care as the signature. Use a UUID or a hash rather than invoice-1041.pdf — the path is part of your security boundary, not just an identifier.
Confirm the object exists before you sign for it
Signing a missing key succeeds and produces a URL that 404s at redemption time, which is a confusing bug report. GET /v1/storage/object/head/{bucket}/{key} is free and answers in one round trip:
curl -sS \
"https://api.infrai.cc/v1/storage/object/head/kb-sig-0726/invoices/tenant_42/2026-07.pdf" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
It returns found, size_bytes, etag, content_type and last_modified without transferring the body — so an existence check costs you nothing and no egress.
Taking a link back
You can’t revoke one outstanding signature. Nobody can — that’s inherent to the design, not an Infrai gap. What you can do is narrow the object itself:
curl -sS -X POST \
"https://api.infrai.cc/v1/storage/object/set_acl/kb-sig-0726/invoices/tenant_42/2026-07.pdf" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"acl":"signed-only"}'
Supported values are private and signed-only; public and public-read aren’t supported, and public_url always comes back null. If a document must genuinely disappear, DELETE /v1/storage/object/delete/{bucket}/{key} is the honest tool. Short expiries are the real control here, and 60–300 seconds costs you nothing.
Where the egress bill lands
| Provider | Signing a download | Bytes leaving | Practical expiry ceiling | Watch out for |
|---|---|---|---|---|
Infrai presign op=get | Free, rate-limited | Metered; STORAGE_BANDWIDTH_EXCEEDED is what a hot object looks like | Set per call in expires_seconds | No CDN in front of the bucket |
| Amazon S3 | Free to sign with the SDK | Billed per GB out to the internet | 7 days on SigV4 with long-lived credentials | Egress is the classic month-end surprise |
| Cloudflare R2 | Free to sign via the S3 API | Zero egress fees | 7 days | Fewer regions than S3; you manage another account |
| Supabase Storage | Free via createSignedUrl | Counted against the plan’s bandwidth allowance, then per GB | Set per call | Compelling mainly if you already run Supabase Postgres and auth |
| Firebase Storage | getDownloadURL returns a token link that doesn’t expire | Billed as Google Cloud Storage network egress | None by default | A leaked download URL stays live until you rotate the token |
That Firebase row is the one people get bitten by. The default getDownloadURL link is a permanent bearer token, not a timed signature, so “private” documents shared that way stay reachable indefinitely unless someone remembers to invalidate them.
For a read-heavy public-ish corpus — product manuals, marketing assets, anything served thousands of times a day — R2’s zero-egress model wins and it isn’t close. Take it.
What the Infrai side costs, and how to re-check it
Structure first, because structure survives price changes: presign, head, list, bucket create and lifecycle rules are free and rate-limited; object writes and object reads are billable per call; new accounts start with $2 of free credit. Reads cost about twice writes, which is the ratio that matters for a document portal.
The figures we read on 26 July 2026 were $0.0001 per object.put call and $0.0002 per object.get call. Don’t trust that for long — ask the API:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c "import json,sys; [print(c['method'], c['path'], c['billing'].get('price_usd')) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.') and c['billing']['is_billable']]"
Rates drift downward and discount campaigns run, so what you find is as likely to be lower as equal. For what you actually spent rather than what you might:
curl -sS "https://api.infrai.cc/v1/account/usage" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c "import json,sys; [print(b['key'], b['calls'], b['cost']) for b in json.load(sys.stdin)['data']['breakdown'] if b['key'].startswith('storage.')]"
Note that the redemption itself — the bytes moving from the bucket to your customer — doesn’t go through object.get. It’s metered as bandwidth. A signed URL is cheaper than proxying the file through your own API for the same reason it’s faster: your process never sees the payload.
Limits worth knowing before you standardise on this
A signed link is fine behind an <a href> or a 302 redirect. Fetching it from JavaScript with fetch() is a cross-origin request, and the Infrai storage surface reports a bucket’s cors_rules but has no route to set them — so browser-side XHR downloads aren’t a good fit today. If your UI must read the bytes in JS (to render a PDF in a canvas, say), stick with S3 or R2 where the CORS rule set is yours to edit.
Two smaller things we noticed in testing. Downloads come back with Content-Disposition: attachment, so the browser saves rather than previews — an inline viewer needs your own proxy route. And a bucket created in eu-central-1 may hand out a signed URL pointing at a different regional host, so if data residency is contractual, read the hostname in the presign response before you promise anything.