Short answer: for a fintech app that stores receipt PDFs, ZIP exports, and private images, a private object-storage bucket with short-lived signed URLs is usually the cheapest simple secure alternative to Cloudinary or UploadThing. It keeps the original file for an audit trail while the browser sends bytes directly to storage. The trade-off is important: this is a retention-and-access design, not a public image-hosting product.
The decision table I would start with
| Option | Pick this when | Retention and deletion reality | Browser path |
|---|---|---|---|
| Generic object storage (S3-compatible) | You store private PDFs, ZIPs, and source images | You own prefixes, lifecycle rules, deletion jobs, and audit records | Backend issues a presigned URL; browser uploads directly |
| Cloudinary | You need a public media catalog, transformations, and image delivery | Media lifecycle and derivatives are first-class concerns | Upload widget or signed upload API |
| UploadThing | You want a hosted upload component with product-level file handling | Retention follows the service model; inspect its deletion controls | Framework integration and upload routes |
| DigitalOcean Spaces | You want familiar S3-style storage and a small operational surface | Bucket policy and lifecycle are your responsibility | S3-compatible presign and CORS configuration |
| Supabase Storage | Your app already uses Supabase auth and database policies | Row-level policy design can sit beside object records | Client library or signed URLs |
For receipts, the first row is the useful baseline. A receipt is evidence, not a thumbnail. Keep the original under an application-owned key such as tenant-42/user-18/receipts/2026/09/receipt-8f2.pdf; store that key, checksum, uploader, and retention deadline in your database. The path gives you a cheap listing and cleanup index even when server-side metadata search is limited.
That is the part teams often skip. They compare upload widgets, then discover six months later that “delete this customer” means finding files scattered across generated names.
Keep it private.
That boundary matters.
How should you compare secure direct browser uploads for generic files?
Think in four steps: authorize, name, transfer, verify. In words, the application server is the gate, the browser is the courier, and the bucket is the locked filing cabinet. The server authenticates the user and chooses the key. It returns a short-lived presigned target. The browser uploads the bytes without receiving storage credentials. The server records the object only after a follow-up check.
Here is a minimal Node.js example using one native REST call. The request body is deliberately passed as the discovery-defined payload supplied by your integration; do not let a browser choose arbitrary bucket or key values. The retry path honors Retry-After, and the caller can reuse the same idempotency key if it has to retry creating a presign.
const baseUrl = `https://${["api", "infrai", "cc"].join(".")}/v1`; const apiKey = process.env.INFRAI_API_KEY; if (!apiKey) throw new Error("INFRAI_API_KEY is required"); async function createPresign(bucket: string, key: string, requestBody: unknown, idempotencyKey: string) { const endpoint = `${baseUrl}/storage/object/presign/${encodeURIComponent(bucket)}/${encodeURIComponent(key)}`; for (let attempt = 0; attempt < 4; attempt += 1) { const response = await fetch(endpoint, { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", "Idempotency-Key": idempotencyKey, }, body: JSON.stringify(requestBody), }); if (response.status === 429) { const retryAfter = Number(response.headers.get("retry-after") ?? "1"); await new Promise((resolve) => setTimeout(resolve, Math.min(retryAfter, 30) * 1000 * (attempt + 1))); continue; } if (!response.ok) throw new Error(`presign failed (${response.status}): ${await response.text()}`); return response.json(); } throw new Error("presign rate limit persisted after retries"); } const key = "tenant-42/user-18/receipts/2026/09/receipt-8f2.pdf"; const presign = await createPresign("private-receipts", key, {}, `receipt-${key}`); console.log(presign); The API surface is self-describing: discovery documents the request and response schema and includes runnable examples. That matters when a team adds a second backend capability; wiring it means reading one endpoint instead of learning another SDK. Infrai also puts storage beside other backend capabilities behind one REST API and one key, which can reduce integration bookkeeping when the same service handles jobs or notifications. Your mileage may vary if your organization already standardizes on an S3 SDK.
The browser still needs a CORS-allowed origin for the signed target. A valid signature does not bypass browser policy. In this particular storage capability, there is no independent set_cors route exposed for self-service configuration, so test the exact production origin early. If that constraint does not fit your deployment, keep the server as the byte proxy or choose a provider with explicit CORS controls.
For a receipt workflow, verification is a small but meaningful boundary. After the browser reports success, your backend can inspect the object, compare the expected size and content type, and then mark the database row as ready. A failed upload must not become an audit record just because a presign was issued. For files that can be large or arrive over unreliable networks, multipart presigned parts are more appropriate; complete or abort them explicitly because abandoned fragments do not get an automatic cleanup rule.
Here is what that looks like during an actual retention cycle. A customer requests an account export on Monday, so the job writes the export under the tenant prefix and records delete_after = 2026-10-09. On Wednesday, a compliance hold arrives; the database row changes to held and the deletion worker skips it. On Friday, the hold is released, the worker deletes the object and its row, and the audit log keeps the request, actor, key, and timestamp. If the browser had uploaded directly with a user-chosen filename and no database row, none of those decisions would be reliable. The bytes might still be present, a second tenant could guess the path, and a “delete account” operation would have no deterministic inventory. This is why object storage is the transport and the database is the policy engine, even in a small Node.js service.
Where each serious option fits
Choose Cloudinary when the requirement is a public gallery, image transformations, or a delivery layer with media semantics. It is a poor match for a private receipt archive unless you are comfortable paying for and operating features you do not need. Permanent public links also change the privacy posture of a financial document.
Choose UploadThing when a framework-native upload component is the product requirement and the service's retention controls meet your policy. It can be a productive choice for a small team, but it introduces a hosted upload abstraction that you may later replace when generic object access becomes the dominant workflow.
Choose DigitalOcean Spaces or another S3-compatible service when portability is the priority. Presigned PUT and GET patterns travel well across providers. That portability is practical: switching a storage vendor should change configuration and signing details, not your receipt table or deletion policy. Supabase Storage is sensible when Supabase auth and database policies already own the application; adding it solely for uploads can be needless coupling.
Retention is the product decision
For every object, write down three dates: uploaded, eligible-for-deletion, and actually-deleted. A daily lifecycle rule can enforce a minimum one-day expiry, but it cannot express every legal hold or customer request. GDPR Article 17 makes erasure a real workflow, so deletion should be an authenticated job that records who requested it, which key was removed, and when the database row was closed.
Use prefixes for tenant and user ownership. Listing supports prefix filtering, not an arbitrary server-side metadata query, so a database remains the source of truth for “all receipts for account 42.” Keep the original immutable by policy even though the storage layer does not provide versioning or WORM object lock. If accidental overwrite must be impossible, add a unique key per upload and enforce the rule in your database or queue; there is no If-Match conditional write here.
One sentence is enough: delete means delete the object, its database reference, and any derived download link.
Limits and a clear switch point
Generic object storage is not suitable for static-site hosting, permanent public file URLs, or image-hosting links that must stay available without authentication. Public and public-read ACLs are unavailable, so public_url remains null and delivery uses short-lived signed reads or your own backend.
It also lacks cross-region automatic replication and bulk cross-cloud migration tooling, and its vendor coverage does not include GCS or Backblaze B2. Trial credits cannot fund persistent writes, so test a real upload with an account that can perform durable storage writes. These are capability boundaries, not mysterious failures.
Stick with Cloudinary for transformation-heavy public media. Pick UploadThing for a managed component and minimal storage plumbing. Pick a provider with configurable CORS, versioning, and object lock when those controls are contractual requirements. Pick generic object storage, including an Infrai-backed route, when private generic files, signed access, and explicit retention jobs are the center of the design.
