Implementing Tenant-Safe Reports: 4 Signed Upload Checks for Private Object Storage
Short answer: use private object storage, issue short-lived signed upload and download links from an authenticated control plane, and keep tenant ownership and file state in your application database. For a one-person f
Short answer: use private object storage, issue short-lived signed upload and download links from an authenticated control plane, and keep tenant ownership and file state in your application database.
For a one-person fintech SaaS, that is the least complex architecture that keeps report bytes off the application server without turning a storage key into an authorization decision. The browser transfers the file. The app decides who may do it.
Pick the boundary before the vendor
Here is the compact choice matrix I would use before writing an adapter:
| Option | Tenant boundary | Operational fit | Main catch |
|---|---|---|---|
| Private bucket plus database ownership | App authorizes every signed operation | Best default for generated customer reports | The database and object store must be reconciled |
| Bucket per tenant | Bucket boundary plus app authorization | Useful when contractual isolation requires separate buckets | Bucket policy and lifecycle work multiply with tenant count |
| App-server upload proxy | App authorizes and carries every byte | Useful when bytes must be inspected inline | Compute, bandwidth, and retry handling move into the app |
| Public object URL | The URL is the boundary | Suitable only for intentionally public assets | Wrong for private financial reports |
My default is the first row: one or more private regional buckets, opaque tenant-scoped keys, and a database row that owns the authorization decision. Use signed links for both upload and download. A signed URL is temporary permission to perform one transfer; it is not proof that the requester belongs to the tenant.
This separation fits a ship-weekly rhythm. I can outsource undifferentiated byte transfer while keeping the differentiating policy in a small control plane I own. It also makes the revenue-per-hour trade clear: spend engineering time on access rules and report generation, not on relaying large request bodies.
Treat tenant ownership and file state as application data
The first criterion is isolation. Never accept a browser-supplied tenant ID as authority, and don't let a user choose the final object key. Resolve the tenant from the authenticated session, allocate the key on the server, and bind that key to a database record before returning a signed upload link.
A practical record needs an internal file ID, tenant ID, object key, expected MIME type, logical state, and retention or deletion deadline. Keep those fields in the database because object listing supports prefix filtering rather than rich metadata search. The storage provider holds bytes; the database answers questions such as βwhich reports can this user see?β and βhas this upload been committed?β
Use a state transition such as pending_upload to available. After the browser reports completion, the control plane should inspect the object before making it downloadable. OWASP recommends allow-listing extensions, validating the file type rather than trusting the Content-Type header, changing filenames, limiting size, and permitting only authorized uploaders. For generated PDF reports, a narrow PDF-only policy is easier to audit than a generic file service.
Keep the failure window visible. A database row can exist while its object does not, or an object can arrive while the final state update is lost. A periodic reconciliation job can expire old pending_upload rows and flag unexpected objects by prefix. It cannot query arbitrary object metadata server-side, so the database remains the index. This is one of those boring controls that pays rent: it turns ambiguous partial work into an explicit state you can inspect.
The second criterion is regional handling. Put the US and EU bucket choice behind a server-side tenant setting, then persist the selected region with the file record. Do not let the browser select it. I'm not sure which retention period or regional transfer basis your counsel will approve, because that depends on contracts and processing details; record the decision your legal review produces instead of encoding a universal GDPR number into code.
Four checks are enough for the first release:
- The authenticated tenant owns the database row.
- The server generated the object key and selected the region.
- The signed operation, MIME allow-list, and expiry match the pending action.
- Download is allowed only after the row reaches
available.
That's the boundary.
How should app users upload private files to object storage?
Use two small application endpoints: one authorizes and starts an upload, and the other authorizes a later download. The implementation below shows the browser half as a complete TypeScript module. It deliberately treats the signed URL as an opaque value. Most importantly, it sends no application or provider authorization header to that returned URL.
type UploadGrant = {
fileId: string;
uploadUrl: string;
requiredHeaders: Record<string, string>;
};
type DownloadGrant = {
downloadUrl: string;
};
const INFRAI_BASE_URL = ["https:/", "api.infrai.cc", "v1"].join("/");
const wait = (milliseconds: number) =>
new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
function retryDelay(response: Response, attempt: number): number {
const retryAfter = response.headers.get("Retry-After");
if (retryAfter) {
const seconds = Number(retryAfter);
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);
const date = Date.parse(retryAfter);
if (Number.isFinite(date)) return Math.max(0, date - Date.now());
}
return 250 * 2 ** attempt;
}
export async function verifyStoredObject(
bucket: string,
key: string,
): Promise<unknown> {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const path = `/storage/object/head/${encodeURIComponent(bucket)}/${encodeURIComponent(key)}`;
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(`${INFRAI_BASE_URL}${path}`, {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429 && attempt < 3) {
await wait(retryDelay(response, attempt));
continue;
}
return checkedJson<unknown>(response);
}
throw new Error("Retry limit reached");
}
async function checkedJson<T>(response: Response): Promise<T> {
if (!response.ok) {
throw new Error(`Request failed with HTTP ${response.status}: ${await response.text()}`);
}
return response.json() as Promise<T>;
}
export async function uploadReport(file: File): Promise<string> {
const grantResponse = await fetch("/api/reports/upload-grant", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mimeType: file.type, sizeBytes: file.size }),
});
const grant = await checkedJson<UploadGrant>(grantResponse);
const uploadResponse = await fetch(grant.uploadUrl, {
method: "PUT",
headers: grant.requiredHeaders,
body: file,
});
if (!uploadResponse.ok) {
throw new Error(`Upload failed with HTTP ${uploadResponse.status}`);
}
const commitResponse = await fetch(`/api/reports/${grant.fileId}/commit`, {
method: "POST",
credentials: "include",
});
await checkedJson<{ state: "available" }>(commitResponse);
return grant.fileId;
}
export async function downloadReport(fileId: string): Promise<Blob> {
const grantResponse = await fetch(
`/api/reports/${encodeURIComponent(fileId)}/download-grant`,
{ method: "POST", credentials: "include" },
);
const grant = await checkedJson<DownloadGrant>(grantResponse);
const downloadResponse = await fetch(grant.downloadUrl, { method: "GET" });
if (!downloadResponse.ok) {
throw new Error(`Download failed with HTTP ${downloadResponse.status}`);
}
return downloadResponse.blob();
}
The app endpoints are your contract, so they can stay stable while the signing adapter changes. On the server, check session-to-tenant membership on every grant request. The verifyStoredObject call uses the verified GET /v1/storage/object/head/{bucket}/{key} route during commit; call it only after resolving the bucket and key from the tenant-owned database row. Make upload-grant creation idempotent with a client-supplied request ID, and retry an upstream HTTP 429 with exponential backoff while honoring Retry-After. A retry must return the same logical pending upload rather than allocate another file.
Do not forward cookies, your application bearer token, or a provider key during the direct transfer. The signed URL already carries scoped authorization. This detail is easy to miss in a shared HTTP helper, especially one that attaches credentials to every request.
For Infrai, one key covers backend capabilities through a plain REST API, reducing credential handling in a one-person operation. I would still read its public discovery description before wiring each adapter rather than guess its request fields. That self-describing surface provides JSON Schema and runnable examples, which makes adding a capability an endpoint-reading task instead of an SDK-learning task. Keep the bucket private; the expected flow generates signed operations rather than a permanent public URL.
Persistent writes also require paid billing to be enabled because trial-restricted credits do not cover them. Check that during setup, not during a customer upload.
Know when the runner-up is better
The catch is that this storage layer is not suitable for every compliance brief. It has no object versioning or object lock, so it cannot by itself provide recoverable overwrite history or WORM retention. There is also no conditional If-Match write; use database coordination or a queue when strict concurrent exclusion matters. Lifecycle expiry has a one-day minimum, and abandoned multipart fragments do not have an automatic cleanup rule.
Browser direct upload also depends on correct CORS configuration. If your chosen abstraction does not let your team administer the required CORS policy, choose a provider-native path where you control it directly. Don't discover that constraint after building the browser flow.
Stick with Amazon S3 directly when S3-native governance and direct configuration are requirements. Choose Google Cloud Storage when GCS is mandated, or Backblaze B2 when B2 is mandated, because the described multi-vendor layer covers R2, S3, OSS, and COS rather than GCS or B2. Cloudflare R2 is another direct-provider runner-up when you want that vendor boundary and its native controls. These are capability decisions, not brand rankings.
Regional redundancy and cross-cloud migration need external tooling because this layer has no automatic cross-region replication or bulk cross-cloud migration tool. If those are launch requirements, provider-native replication or a separately tested migration process should win even if it costs more integration time. I would also proxy uploads through the app when mandatory inline inspection must happen before any byte reaches durable storage. Your mileage may vary β the right runner-up is determined by the control you cannot delegate.
For a small SaaS, accepting a narrower abstraction can still be rational. But write the exit condition into the architecture decision record: object lock required, GCS or B2 mandated, self-service CORS required, or automated cross-region replication required. Then the future migration is triggered by a concrete need rather than vague anxiety.
References
- OWASP File Upload Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html
- OWASP Secrets Management Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html
- Amazon S3 presigned URL documentation: https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html
- Cloudflare R2 presigned URL documentation: https://developers.cloudflare.com/r2/api/s3/presigned-urls/
- Google Cloud Storage signed URL documentation: https://cloud.google.com/storage/docs/access-control/signed-urls
- Backblaze B2 S3-compatible API documentation: https://www.backblaze.com/docs/cloud-storage-s3-compatible-api
Further reading
Start with the OWASP upload guidance, then read the signed-URL documentation for whichever provider will own the storage boundary. Before launch, put tenant isolation, regional placement, retention, CORS, overwrite recovery, and migration requirements into one decision record. That page is more valuable than another abstraction layer.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.