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”. That model is wrong on most platforms in subtle ways and wrong here in a blunt one, so start by testing it rather than assuming it.

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: 200

Both succeed. We repeated it after setting the object’s ACL to signed-only via POST /v1/storage/object/set_acl/{bucket}/{key} and the unsigned request still returned 200. So on this platform today the signature buys you an expiry timer and a tidy way to hand a URL to a browser — it is not an access check, and treating it as one is the mistake that ends up in an incident report.

That single fact reshapes the architecture. Three things have to carry the weight instead.

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, which is what stands between an unauthenticated stranger and the file. 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 one billable read per view 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.

ControlWhat it really enforcesWhere it lives
Bucket ACL private / signed-onlyBucket isn’t listable; no permanent public URL is ever issuedInfrai
Presigned URLAn expiry timer, and a URL a browser can useInfrai
Key entropyWhether a stranger can find the object at allYour code
Authorisation before signingWho is allowed to get a linkYour code
Proxying bytesRevocation that takes effect immediatelyYour 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": "eu-central-1",
    "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 accepts a region, and the bucket record faithfully reports eu-central-1 back to you. In our testing the presigned URL for an object in that bucket pointed at an ap-singapore host. For a beginner project that’s a footnote; for a controller making transfer commitments under EU data protection law it isn’t, so read the presign host yourself and treat residency as unproven until you have. That’s the sharpest limitation on this page.

What it costs, and when to use something else

Verified 26 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; storage.object.put is $0.0001 per call and storage.object.get $0.0002 — so the proxy pattern above is the only expensive habit in this design. Storage rent and egress are 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 an application that also sends mail, runs jobs and calls models on the same key — and when you’re prepared to put the access decision in your own route, where this architecture says it belongs anyway.

References

Browse more storage developer guides