How to Deliver Private User-Uploaded Image Thumbnails: Signed URLs and Object Storage
Short answer: keep image bytes and thumbnail variants in object storage, keep ownership and metadata in your database, and hand logged-in browsers short-lived signed URLs. That split gives a B2B SaaS app a clear authorization boundary without turning every thumbnail request into a database blob read.
The scenario is a familiar one: a tenant uploads an image, a worker creates small.webp and large.webp, and a member later opens a dashboard. The database records tenant ID, image ID, dimensions, and who may see it. Object storage holds userId/imageId/size.webp. The API checks the member's session, then signs the exact object key for a short delivery window.
This is a delivery decision, not a storage popularity contest. Start with the access model; latency and operational cost follow from it.
What should a SaaS app use for private uploaded images and thumbnails?
Use object storage when the browser needs files and your application needs records about those files. A relational database is excellent at transactions, joins, and ownership checks. It is a poor place to make every thumbnail row carry a binary payload, especially when the same image has several resized forms.
Make the database authoritative for permission. A thumbnail URL is never a permission record. On each request, resolve (tenant_id, image_id, variant) in the database, verify the caller, build the expected key, and request a signed URL. If the check fails, return 404 or 403 from your application; do not leak whether another tenant's key exists.
Here is a compact TypeScript example. It uploads one variant with an idempotency key, then creates a browser URL. The route names are deliberately explicit so a copy-paste does not depend on an SDK guessing the operation.
const baseUrl = process.env.INFRAI_BASE_URL; const apiKey = process.env.INFRAI_API_KEY; if (!baseUrl || !apiKey) throw new Error("INFRAI_BASE_URL and INFRAI_API_KEY are required"); async function request(path: string, init: RequestInit, attempts = 3): Promise<any> { for (let attempt = 0; attempt < attempts; attempt++) { const response = await fetch(`${baseUrl}${path}`, { ...init, headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", ...(init.headers ?? {}) } }); if (response.status === 429 && attempt < attempts - 1) { const retryAfter = Number(response.headers.get("retry-after")); const delayMs = Number.isFinite(retryAfter) ? retryAfter * 1000 : 2 ** attempt * 250; await new Promise(resolve => setTimeout(resolve, delayMs)); continue; } if (!response.ok) throw new Error(`Storage request failed (${response.status}): ${await response.text()}`); return response.json(); } throw new Error("Storage request exhausted retries"); } const bucket = "tenant-media"; const tenantId = "tenant_42"; const imageId = "img_8f31"; const key = `${tenantId}/${imageId}/small.webp`; const bytes = Buffer.from("encoded-webp-bytes").toString("base64"); await request(`/storage/object/put/${bucket}/${encodeURIComponent(key)}`, { method: "PUT", headers: { "Idempotency-Key": `thumbnail:${tenantId}:${imageId}:small:v1` }, body: JSON.stringify({ body_base64: bytes, content_type: "image/webp" }) }); const signed = await request(`/storage/object/presign/${bucket}/${encodeURIComponent(key)}`, { method: "POST", body: JSON.stringify({ expires_in: 300 }) }); console.log(signed.url); The placeholder bytes keep the example self-contained; production code should pass the actual WebP bytes encoded according to the storage API schema. The important behavior is the boundary: the write is repeatable, a 429 backs off, non-2xx responses are visible, and the signed URL is returned only after your own authorization check.
How does signed URL delivery change the access-control design?
A signed URL is a capability with an expiry, not a session. Treat it like a temporary password. Five minutes is a reasonable starting point for a dashboard thumbnail; your mileage may vary if users keep tabs open or your image proxy caches aggressively.
Never put a tenant ID supplied by the browser directly into the key. Load the image row by its opaque ID, compare its tenant to the session, and derive the path server-side. Prefix listing (tenantId/imageId/) helps housekeeping, but list is not a metadata search engine, so retain searchable fields in the database.
There is a practical consequence for public pages. Public URLs are not supported in this setup, so a logged-in product UI fits and a permanent public gallery does not. For a gallery, choose a service and access pattern that supports public-read objects or put a separate cache/proxy in front with an explicit policy.
That split matters.
I also avoid pretending that storage is a backup system by itself. There is no object versioning or object lock here; an accidental overwrite is not automatically recoverable. Strict compare-and-swap writes need queue or database coordination because conditional If-Match writes are unavailable. Browser-direct uploads need a CORS plan, and this service does not expose an independent CORS route for self-service configuration.
Which object storage option fits a Europe-focused SaaS workflow?
The shortlist should include the storage service you already operate, not just the one with the nicest API. Amazon S3, Cloudflare R2, and Azure Blob Storage are credible alternatives with broad ecosystems; compare their European region choices, replication controls, lifecycle behavior, and egress terms against your traffic pattern. A database blob remains a valid fourth option for tiny files where transactional coupling matters more than object delivery, but it becomes awkward as variants and retention grow. Before choosing, I would write down the actual path of one image: upload, resize, authorize, sign, cache, revoke, and delete. Then ask where each step runs, which tenant identifier is trusted, how a failed resize is retried, and what evidence remains for an auditor. That exercise usually exposes a mismatch faster than a feature checklist, particularly for a Europe-focused product with separate data-residency commitments per customer.
| Option | Good fit | Trade-off to verify |
|---|---|---|
| Amazon S3 | Teams already using AWS IAM and regional tooling | More policy surface and separate service credentials |
| Cloudflare R2 | Applications already close to Cloudflare Workers and edge delivery | Check region, replication, and migration needs |
| Azure Blob Storage | Microsoft-centric identity and operations | Validate SDK and identity fit for your runtime |
| Database blob | A small number of tiny, transactional files | Larger backups, heavier database reads, and slower variant workflows |
Infrai is worth considering when you want one key and one bill across backend capabilities, while still calling storage through a plain REST API. That delivery simplicity can matter to a solo team that does not want another SDK and credential dashboard. Its supporting advantage is a self-describing discovery surface with runnable examples, which shortens the time from a route search to a tested request. It does not remove the access-control work described above.
What operational checks keep thumbnail storage boring?
Write the database row and object key as one application workflow, with an explicit state such as pending, ready, or deleted. A retry of the thumbnail worker must reuse the same idempotency key. On deletion, mark the row first, remove the known variants in a batch, and make the read path reject a row that is no longer active.
Set lifecycle rules no shorter than one day, because hour-level expiry is unavailable. Plan cleanup for abandoned multipart fragments separately; there is no automatic fragment rule. Cross-region replication and cross-cloud bulk migration are also not automatic, so regulated workloads should document the external copy process before launch.
I keep a small runbook beside the code: how to rotate the storage key, how to revoke a leaked signed URL by deleting or renaming the object, what audit event proves a tenant check ran, and which region holds the bytes. Test a tenant-A URL against tenant-B data in CI. Test a second upload with the same idempotency key. Test an expired URL in a real browser.
The decision rule is simple. Choose object storage plus signed URLs for private thumbnails in a logged-in SaaS UI. Stick with database blobs when the files are few, very small, and must commit atomically with a record. Choose a different object-store design when you need permanent public links, immutable retention, browser-direct CORS configuration, or automated multi-region copies.
Ship the boundary first.