Signed URLs for private document downloads: who pays the egress
Download-side presigned links on Infrai, what the signature actually enforces, and how expiry, revocation and the egress bill compare against the usual alternatives.
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 mechanism isn’t where providers differ — who bills you for the bytes that leave is.
Decide that part first, because a document portal is read-heavy by nature. One contract is uploaded once and downloaded fifty times over its life, so the read path and the egress model set 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-userdocs-0726/invoices/tenant_42/2026-07.pdf" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op":"get","expires_seconds":300,"response_disposition":"attachment; filename=\"july-invoice.pdf\""}'
{
"ok": true,
"data": {
"url": "https://infrai-1333115350.cos.ap-singapore.myqcloud.com/a4ee0c441fa36c267.kb-userdocs-0726/invoices/tenant_42/2026-07.pdf?response-content-disposition=attachment%3B%20filename%3D%22july-invoice.pdf%22&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=20260727T001704Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&X-Amz-Signature=9c5fcf64eaacde8dc3d316a548e2877",
"expires_at": "2026-07-27T00:22:04.922293Z"
}
}
It’s an AWS SigV4 URL. X-Amz-Expires=300 is the five minutes you asked for, and X-Amz-SignedHeaders=host means the client reproduces no headers to redeem it — a browser can follow it, curl can follow it, and so can anyone the recipient forwards it to. expires_seconds accepts 1 to 604800; outside that range you get STORAGE_INVALID_TTL naming the bounds.
What the signature enforces
Worth establishing before the comparison, because it decides whether “private” means anything here. We took a working link apart on 27 July 2026:
| Request | Result |
|---|---|
| The signed URL, unmodified | 200 with the body |
| Query string stripped — bare object path | 403 |
| Four characters of the signature changed | 403 |
Redeemed after expires_at | 403 |
| Valid signature over a key that doesn’t exist | 404 |
HEAD against a URL signed for GET | 403 |
So the object isn’t reachable without a current signature over that exact key and method, and 403-versus-404 tells you whether you’re looking at a credential problem or a missing file. POST /v1/storage/object/set_acl/{bucket}/{key} accepts private and signed-only; public-read returns STORAGE_ACL_INVALID, and public_url stays null in every case. There is no public mode to leave switched on by accident.
The trade-off: a presigned URL is a bearer token. Anyone holding a live one can read the object, and there’s no revocation call — expiry is the only lever, so keep it short and make keys unguessable.
The authorization decision happens before the signature
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-userdocs-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 signed = await fetch(`${API}/v1/storage/object/presign/${BUCKET}/${invoice.object_key}`, {
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 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 long enough for a browser to start the transfer, short enough that a link pasted into a support ticket is dead before anyone reads it.
Give keys the same care as the signature: a UUID or a hash, never invoice-1041.pdf.
Confirm the object exists before you sign for it
Signing a missing key succeeds and produces a URL that 404s at redemption, which makes for a confusing bug report. head is free and settles it in one round trip:
curl -sS \
"https://api.infrai.cc/v1/storage/object/head/kb-userdocs-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 moving the body, so an existence check costs no egress at all.
Where the egress bill lands
| Provider | Signing a download | Bytes leaving | Expiry ceiling | Watch out for |
|---|---|---|---|---|
Infrai presign op=get | Free, rate-limited | Metered per GB; STORAGE_BANDWIDTH_EXCEEDED is what a hot object looks like | 604800 seconds, set per call | 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 | Egress is the classic month-end surprise |
| Cloudflare R2 | Free to sign via its S3 API | Zero egress fees | 7 days | Fewer regions; another account to run |
| Supabase Storage | Free via createSignedUrl | Counted against the plan allowance, then per GB | Set per call | Compelling mainly if you already run its database and auth |
For a read-heavy, nearly-public corpus — product manuals, marketing assets, anything served thousands of times a day — the zero-egress model wins and it isn’t close. Buy R2 for that workload and don’t feel bad about it. Buy Supabase Storage if your app already lives there and you want per-file rows with row-level security handed to you.
What none of them do is the next paragraph.
One bill, and cost per tenant as a query
The key that signed the link also runs the queue that generated the document, the cron that expires it and the mail that announced it, so spend across the whole workflow lands on one account and one usage view:
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.')]"
Attributing a month of document delivery to a tenant is a query against that breakdown plus your own key prefixes — not a reconciliation project across a storage invoice, a mail invoice and a job-runner invoice with three different billing periods.
What the Infrai side costs
Structure first, because structure survives price changes: presign, head, list, bucket create and lifecycle rules are free and rate-limited; writes are billed per call; reads are billed by volume. Verified 27 July 2026, PUT /v1/storage/object/put/{bucket}/{key} is $0.0001 per call.
The read side moved to a volume meter, which matters for a document portal: GET /v1/storage/object/get/{bucket}/{key} is $0.104 per GB of response body, so a hundred downloads of a 2 MB contract cost the same as one download of a 200 MB export. Redeeming a signed URL doesn’t go through that route at all — the bytes come from the storage host and are metered as bandwidth — which is why a redirect is cheaper than proxying the file through your own API, as well as faster. Ask the API for today’s numbers:
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'), c['billing'].get('unit')) 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.
One limitation to design around: there’s no CDN or custom domain in front of the bucket, so every redemption is an origin fetch from the storage region. For a global audience pulling the same few documents constantly, that latency — and the egress it bills — is the reason to put a cache in front, or to keep that particular corpus somewhere with a bundled CDN.