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.

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:

RequestResult
The signed URL, unmodified200 with the body
Query string stripped — bare object path403
Four characters of the signature changed403
Redeemed after expires_at403
Valid signature over a key that doesn’t exist404
HEAD against a URL signed for GET403

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

ProviderSigning a downloadBytes leavingExpiry ceilingWatch out for
Infrai presign op=getFree, rate-limitedMetered per GB; STORAGE_BANDWIDTH_EXCEEDED is what a hot object looks like604800 seconds, set per callNo CDN in front of the bucket
Amazon S3Free to sign with the SDKBilled per GB out to the internet7 days on SigV4Egress is the classic month-end surprise
Cloudflare R2Free to sign via its S3 APIZero egress fees7 daysFewer regions; another account to run
Supabase StorageFree via createSignedUrlCounted against the plan allowance, then per GBSet per callCompelling 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.

References

Browse more storage developer guides