Private SaaS Document Storage with Signed Links and 1-Day Retention (Trade-offs)
Private files change the storage decision. For a SaaS app holding invoices and PDFs, the useful default is a private bucket, an application-owned document index, and a short-lived presigned URL for each download. Keep re
Private files change the storage decision. For a SaaS app holding invoices and PDFs, the useful default is a private bucket, an application-owned document index, and a short-lived presigned URL for each download. Keep retention and deletion in your database and lifecycle policy; do not make a public URL the record of truth.
Short answer: Infrai fits this workflow when access stays private and signed links are enough, while Amazon S3, Cloudflare R2, DigitalOcean Spaces, and Backblaze B2 remain better choices when you need public objects, versioning, object lock, or mature replication controls.
The retention problem is bigger than the bucket
Uploading a file is the easy part. The hard questions arrive later: which account owns invoice-1842.pdf, when should an export disappear, and what happens if a worker retries a delete after a customer has restored access? A bucket key alone cannot answer those questions, so I keep filename, tenant ID, retention date, and deletion state in the SaaS database. Object metadata is useful for hints, but it is not a query system here; server-side listing is limited to a prefix.
For a private document, the request path is deliberately boring. The browser uploads or downloads through a presigned URL, the API checks the logged-in user and the database row, and the object remains private. A signed URL is a capability with an expiry, not a permanent permission. AWS describes the same model for S3 presigned URLs, and the security boundary still depends on how your application authenticates before issuing one. In practice, I have seen teams spend more time arguing about URL duration than documenting who can issue a URL; the latter is the control that belongs in code review, audit logs, and tests.
Keep the permission check close to the database.
The one-day floor matters. Lifecycle rules can purge old exports or backups after at least one day, but they do not express an hourly expiry. If your product promises βdelete in 30 minutes,β run that schedule in a worker and call an explicit delete operation; let lifecycle handle the less urgent cleanup.
What private object storage should a SaaS user choose for documents?
The products below can all hold private objects, but their operational edges differ. βS3-compatibleβ describes an interface, not identical guarantees.
| Option | Private signed links | Retention and deletion fit | Important trade-off |
|---|---|---|---|
| Amazon S3 | Presigned URLs and mature IAM patterns | Strong lifecycle, versioning, and Object Lock options | More configuration and vendor-specific controls to learn |
| Cloudflare R2 | S3-compatible signed requests | Good for lifecycle-driven cleanup; check replication needs | Public delivery and edge integration are central to its ecosystem |
| DigitalOcean Spaces | S3-compatible private objects and signed URLs | Straightforward lifecycle for common app storage | Smaller feature surface for compliance-heavy retention |
| Backblaze B2 | S3-compatible API plus native tooling | Lifecycle rules are available; verify restore and lock requirements | Cross-region and migration design needs deliberate planning |
| Infrai storage | Private buckets with presigned upload/download URLs | At least one-day lifecycle; deletion can be driven by your worker | No versioning, object lock, public-read ACL, or server-side metadata queries |
Infrai's practical advantages are the plain REST surface and the one key, one bill model: any language that can send HTTP can request a presigned URL, a solo team does not install another storage SDK, and one credential can cover other backend capabilities. Its public discovery document describes request and response schemas plus runnable examples, while the platform exposes 295 routes across 20 modules, so a small SaaS can avoid another pile of tokens and invoices as it adds backend features. Those are integration benefits, not proof that its retention semantics beat S3.
The catch is important. There is no object versioning or object lock, so an accidental overwrite is not recoverable by the storage layer and compliance-grade immutability needs an external backup or process. There is also no public/public-read ACL, which makes permanent public document links, static hosting, and image hosting a poor fit. Browser-direct uploads need CORS rules supplied by the bucket configuration; there is no separate self-service CORS route in the supported surface.
Stick with S3 when legal hold, WORM retention, cross-region replication, or deep IAM policy composition is a requirement. Choose R2 when its edge delivery model is central. Spaces or B2 can be sensible for a smaller operational footprint, but check their current replication and retention details against your compliance checklist. Your mileage may vary by region and contract.
A small presign service with explicit expiry
This example keeps authorization in the application and asks the storage API only for a signed download. The documented route is POST /v1/storage/object/presign/{bucket}/{key}; the request body and response are passed through so the database remains the source for ownership and retention fields. For writes, use PUT /v1/storage/object/put/{bucket}/{key} with an idempotency key, and read with GET /v1/storage/object/get/{bucket}/{key}. Every request should set its HTTP method explicitly, read the response status, and back off on 429 with Retry-After when present.
The worker records the job state before and after deletion. A retry must be safe to repeat. Tiny detail, large consequence.
Here is the complete request shape in TypeScript. Set INFRAI_BASE_URL to the service's versioned API base and keep the key outside source control.
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");
const bucket = "tenant-private";
const key = "invoices/invoice-1842.pdf";
const endpoint = `${baseUrl}/storage/object/presign/${encodeURIComponent(bucket)}/${key
.split("/")
.map(encodeURIComponent)
.join("/")}`;
let response: Response | undefined;
for (let attempt = 0; attempt < 3; attempt += 1) {
response = await fetch(endpoint, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": `presign-${bucket}-${key}`,
},
body: JSON.stringify({ method: "GET", expires_in: 900 }),
});
if (response.status !== 429) break;
const retryAfter = Number(response.headers.get("retry-after"));
await new Promise((resolve) => setTimeout(resolve, Number.isFinite(retryAfter) ? retryAfter * 1000 : 250 * 2 ** attempt));
}
if (!response || !response.ok) throw new Error(`Presign failed: ${response?.status} ${await response?.text()}`);
const { url } = (await response.json()) as { url: string };
console.log(url);
What to measure before committing
Run a small retention drill with representative PDFs: issue a 15-minute download link, revoke the database permission, execute the scheduled delete, and confirm that a new link is no longer issued. Then test an overwrite, a duplicate worker retry, and a browser upload from your real domains. CORS behavior is browser-enforced, so validate it with the same origins your production UI will use.
Measure time-to-delete, restore procedure, egress pattern, and the audit trail your support team actually needs. Storage price is only one line in that worksheet. If the test reveals a need for immutable history or public distribution, change the storage choice before launch instead of layering a fragile workaround on top.
References
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.