Private file uploads: what a signed URL protects, and what it doesn't
An architecture for user file uploads and downloads on Infrai storage, with the signature's real job, key design, EU retention and erasure spelled out.
A private upload flow has four moving parts: a bucket nobody can list, keys the client can’t guess, a route that decides who may read what, and a link with a short life. Infrai gives you the first, the fourth and the plumbing; the middle two are yours to build, and they’re the ones that actually carry the security.
If you’re new to object storage, the tempting mental model is “signed URL means protected file”. It’s half right, and the wrong half is the one that ends up in an incident report — so start by testing it rather than assuming either way.
Run this test before you design anything
Ask for a signed download link, fetch it, then fetch the same URL with the query string cut off:
export INFRAI_API_KEY="your_infrai_api_key"
SIGNED_URL=$(curl -sS -X POST \
"https://api.infrai.cc/v1/storage/object/presign/kb-vaulteu-0726/u/usr_501/kyc/2026/passport-01JZ8V.pdf" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op":"get","expires_seconds":120}' \
| python3 -c "import json,sys; print(json.load(sys.stdin)['data']['url'])")
curl -sS -o /dev/null -w 'signed: %{http_code}\n' "${SIGNED_URL}"
curl -sS -o /dev/null -w 'unsigned: %{http_code}\n' "${SIGNED_URL%%\?*}"
signed: 200
unsigned: 403
Cut the query string and the storage host answers AccessDenied without ever going to look for the object. The signature is the access boundary, the bucket is genuinely closed, and you can tighten it further with POST /v1/storage/object/set_acl/{bucket}/{key}.
Now the half people get wrong. A presigned URL is a bearer credential: whoever holds it is authorised for that one object until the clock runs out, session or no session. It answers “is this link valid”, never “is this person still allowed” — and those two questions diverge the moment a user is suspended, a share is revoked, or a link lands in a group chat.
Three things have to carry that second question.
One: keys the outside world cannot guess
The URL path is the object key, so key entropy is real protection. Derive keys on the server from a random identifier, never from the uploaded filename:
u/{user_id}/{doc_type}/{yyyy}/{ulid}.pdf good — server-derived, unguessable tail
uploads/invoice.pdf bad — guessable, and collides
uploads/{original_filename} worse — path traversal and encoding bugs
A ULID or UUID tail gives roughly 128 bits of unguessability, so a key that leaks into an access log, a Referer header or a support screenshot still tells an attacker nothing about the neighbouring objects. Keep the human-readable part in your database, not in the path.
Two: the route that decides, before it signs
Authorisation happens in your code, in the request that mints the link — and it should read from your own ownership table, not from anything the client sent:
import express from "express";
import { Pool } from "pg";
import { requireSession } from "./session.mjs";
const API = "https://api.infrai.cc";
const BUCKET = "kb-vaulteu-0726";
const LINK_BODY = JSON.stringify({ op: "get", expires_seconds: 120 });
const token = process.env.INFRAI_API_KEY;
if (!token) throw new Error("INFRAI_API_KEY is not set");
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const app = express();
app.post("/documents/:id/link", requireSession, async (req, res) => {
try {
const { rows } = await pool.query(
"SELECT storage_key FROM documents WHERE id = $1 AND owner_id = $2 AND deleted_at IS NULL",
[req.params.id, req.session.userId],
);
if (!rows.length) return res.status(404).json({ error: "not found" });
const signed = await fetch(`${API}/v1/storage/object/presign/${BUCKET}/${rows[0].storage_key}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: LINK_BODY,
});
const slot = await signed.json();
if (!signed.ok || slot.ok === false) throw new Error(slot?.error?.code ?? `HTTP ${signed.status}`);
await pool.query(
"INSERT INTO document_access_log (document_id, actor_id, expires_at) VALUES ($1,$2,$3)",
[req.params.id, req.session.userId, slot.data.expires_at],
);
res.json({ url: slot.data.url, expires_at: slot.data.expires_at });
} catch (err) {
console.error("link failed", err);
res.status(502).json({ error: "could not issue link" });
}
});
app.listen(3000);
Two minutes is a deliberate choice for identity documents. expires_seconds accepts anything from 1 to 604800, and a value at the top of that range is a week-long bearer credential sitting in someone’s browser history.
Three: proxy the files that genuinely can’t leak
For passport scans, medical records or signed contracts, don’t hand out a link at all. Read the object through GET /v1/storage/object/get/{bucket}/{key} inside an authenticated route and stream the bytes to the user yourself. You pay for every gigabyte you relay and you buffer the object in memory, which rules the pattern out for large files — but it’s the only design where an expired session actually stops a download in progress.
| Control | What it really enforces | Where it lives |
|---|---|---|
Bucket ACL private / signed-only | Nothing serves without a valid signature; no permanent public URL exists | Infrai |
| Presigned URL | A time-boxed bearer credential for exactly one object and one method | Infrai |
| Key entropy | Whether a leaked or logged key exposes its neighbours | Your code |
| Authorisation before signing | Who is allowed to obtain a link at all | Your code |
| Proxying bytes | Revocation that takes effect immediately | Your code |
Retention and erasure
EU obligations land on two verbs: keep it no longer than you said, and delete it when asked. Bucket lifecycle covers the first without a cleanup job:
curl -sS -X POST \
"https://api.infrai.cc/v1/storage/bucket/set_lifecycle/kb-vaulteu-0726" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"rules":[{"prefix":"u/","expire_days":365},{"prefix":"scratch/","expire_days":1}]}'
{
"ok": true,
"data": {
"bucket_id": "bkt_2ae1746ac6644c289a3153",
"name": "kb-vaulteu-0726",
"region": "ap-singapore",
"acl": "private",
"lifecycle_rules": [
{ "prefix": "u/", "expire_days": 365 },
{ "prefix": "scratch/", "expire_days": 1 }
]
}
}
Erasure requests are a batch delete keyed on your own metadata — list the user’s keys from your documents table, then send up to 1000 of them in one free call. Missing keys come back as errors rather than failing the request, which is what you want when a retry runs twice:
curl -sS -X POST \
"https://api.infrai.cc/v1/storage/object/delete_batch/kb-vaulteu-0726" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"keys":["u/usr_501/tmp/preview-01JZ8V.pdf","u/usr_501/does-not-exist.pdf"]}'
{
"ok": true,
"data": {
"deleted": ["u/usr_501/tmp/preview-01JZ8V.pdf"],
"errors": [{ "key": "u/usr_501/does-not-exist.pdf", "code": "STORAGE_OBJECT_NOT_FOUND" }]
}
}
Check what a bucket is currently configured for at any time — this call is free:
curl -sS "https://api.infrai.cc/v1/storage/bucket/get/kb-vaulteu-0726" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Where the bytes actually sit
bucket/create takes a region and enforces it, which is worth knowing before you promise anything in a data-processing agreement. There is exactly one region to have:
curl -sS -X POST "https://api.infrai.cc/v1/storage/bucket/create" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"name":"kb-vaultfra-0727","region":"eu-central-1","acl":"private"}'
{
"ok": false,
"error": {
"code": "INVALID_ARGUMENT",
"http_status": 400,
"message": "COS is physically provisioned in ap-singapore; requested region eu-central-1 is unavailable",
"retryable": false
}
}
Infrai object storage lives in Singapore, and the API says so at the exact moment you’d want to hear it rather than eighteen months later in an audit. For a side project that’s a footnote. For a controller making transfer commitments under EU law it’s decisive, and it’s the sharpest limitation on this page: if your DPA names a jurisdiction, those documents belong with a provider that will sell you a bucket in it.
What it costs, and when to use something else
Verified 27 July 2026: presign, head, list, set_acl, set_lifecycle and delete_batch are free and rate-limited and don’t consume the $2 of trial credit a new account gets. Every link this architecture mints and every erasure batch it runs is therefore free.
Uploads meter per call — storage.object.put is $0.0001 however heavy the file.
The proxy route meters by volume — storage.object.get bills $0.104 per GB of egress. That’s precisely why it’s reserved for the documents that must never leave on a link: a 2 MB passport scan proxied on every view is priced on those 2 MB each time, while the same file behind a signed link never touches the route. Storage rent is metered separately. Read today’s rates rather than trusting a page:
curl -sS https://api.infrai.cc/v1/discovery \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c "import json,sys; [print(c['id'], c['billing'].get('price_usd','free')) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.')]"
These rates trend down and campaigns run, so expect the live figure to be at or below the one above; GET /v1/account/usage shows what you were really charged.
If your compliance story depends on the storage layer enforcing access — IAM conditions, bucket policies, per-object rules that hold whether or not your app is correct — then S3 is the right tool and you should use it. Self-hosted MinIO in a Frankfurt rack is the answer when residency has to be provable to an auditor. Supabase Storage is a reasonable middle ground if you already run Postgres there and want row-level policies over files. Infrai storage earns its place when file handling is one part of a larger application, because the next step is already on the same account: POST /v1/queue/publish for the virus scan or the OCR pass, POST /v1/errors/capture for the upload that dies mid-stream, POST /v1/email/send for the “your document was accepted” mail, and one usage view that attributes all of it per tenant. No second account, no second key to rotate, no second invoice. The condition is that you’re prepared to put the access decision in your own route — which is where this architecture says it belongs anyway.