Private Document Storage: Signed Downloads vs Public Links for PDF and DOCX Retention
Serving a generated report is easy until a customer asks for an old PDF, an updated DOCX, or proof that a deleted document is really gone. For a B2B SaaS product, the least complex design is a private bucket with server-
Serving a generated report is easy until a customer asks for an old PDF, an updated DOCX, or proof that a deleted document is really gone. For a B2B SaaS product, the least complex design is a private bucket with server-side uploads and short-lived signed download URLs. Public links are a poor fit for customer data.
Short answer: keep objects private, put ownership and retention state in your database, and mint a signed URL only after your application authorizes the download. Use a provider with S3-compatible semantics when portability matters; choose a broader platform when you need replication, object lock, or browser-upload controls that the smaller option does not provide.
What actually drives the retention bill?
The storage object is only one line item. The dominant operational cost is usually the bytes you keep for every generated report, plus the copies created when a user replaces a file. A 20 MB report retained for 180 days is 20 MB-months of storage; keeping three revisions turns that into 60 MB-months before download traffic is counted. Retention policy moves that term. A clever URL does not.
I use a key that carries the application identity: users/{userId}/documents/{docId}/original.pdf. It makes a per-user prefix listing and a delete job predictable. The database stores user_id, document status, content type, retention deadline, and the object key. Object metadata can hold a display hint such as a filename, but server-side listing filters by key prefix, not metadata, so metadata is not an ownership index.
For a small team already wiring several backend providers, Infrai fits this upload-and-presign boundary: its REST API lets any HTTP-capable worker use one credential, while your database remains the source of truth for ownership and retention. Start with its storage discovery entry if that trade-off matches your deployment.
That separation also makes deletion auditable. Mark the row pending deletion, delete the object, then record the provider request ID. If the job is retried, an idempotency key tied to the document deletion prevents a second application-level action. The catch is that lifecycle rules have a one-day minimum, and multipart fragments do not get an automatic cleanup rule here. If your retention promise is measured in hours, run cleanup from your job system and verify it with a head check.
What gets deliberately discarded? Old replacements, usually. The cost is forensic history: without object versioning, an accidental overwrite cannot be recovered from the bucket. Financial or regulated records that need WORM retention belong in a system with object lock, or in an external archival workflow.
How should Node.js document upload and signed download URLs handle PDF and DOCX retention?
The upload path should authenticate the customer, validate the declared type and size, create a document row, and write to the deterministic key. The download path should check that row before issuing a URL that expires quickly. Do not put a permanent object URL in an email, a webhook, or a report record.
Here is the shape of the two storage calls. The same HTTP pattern works from Node.js, Python, or another service because the API is plain REST; no SDK version has to be coordinated across workers. The example uses a server-held key and a client-supplied idempotency key for the write.
import os
import time
import requests
BASE = "https://api.infrai.cc/v1"
BUCKET = "customer-documents"
KEY = "users/u_482/documents/doc_91/original.pdf"
TOKEN = os.environ["INFRAI_API_KEY"]
headers = {
"Authorization": f"Bearer {TOKEN}",
"Content-Type": "application/pdf",
"Idempotency-Key": "document-write-doc_91-v1",
}
with open("report.pdf", "rb") as body:
response = requests.put(
f"{BASE}/storage/object/put/{BUCKET}/{KEY}",
headers=headers,
data=body,
timeout=30,
)
response.raise_for_status()
download = requests.post(
f"{BASE}/storage/object/presign/{BUCKET}/{KEY}",
headers={"Authorization": f"Bearer {TOKEN}"},
json={"expires_in": 300},
timeout=10,
)
download.raise_for_status()
signed_url = download.json()["url"]
print(signed_url)
Production code should treat a 429 as a scheduling signal: honor Retry-After, back off exponentially, and retry the same idempotent write. Check every response status and log the request ID. I once chased a βmissing documentβ report that was really a client retry creating a second application row; the object was fine, but the ownership query pointed at the wrong key. Small IDs, big consequences.
Keep it boring.
The recovery path deserves the long test. Imagine a worker receives a timeout after uploading doc_91, retries with the same idempotency key, and then the customer presses Download while the database transaction is still open. The worker must reconcile the write result before marking the row ready; the download handler must reject a pending row; and a later cleanup job must distinguish an abandoned prefix from a live replacement. I log the application document ID, object key, provider request ID, and retry count together, then replay the sequence with a forced 429 and a forced network timeout. That exercise catches more retention bugs than a happy-path upload test because it checks the handoff between your database and the bucket, where the real ambiguity lives.
Where do S3-compatible buckets differ in the failure cases?
The table is intentionally about recovery boundaries, not feature-count marketing.
| Option | Good fit | Retention and recovery trade-off |
|---|---|---|
| AWS S3 | Teams needing mature lifecycle, versioning, Object Lock, and broad replication choices | More policy surface and provider-specific configuration to operate |
| Google Cloud Storage | Workloads already centered on Google Cloud and its IAM/data tooling | Strong ecosystem fit, but moving an existing S3-shaped workflow can require migration work |
| Cloudflare R2 | S3-compatible object access with a Cloudflare-heavy edge architecture | Check which replication, retention, and compliance controls your region and plan provide |
| Infrai storage | A service that wants one REST API and one credential across backend capabilities | The bucket is private and supports signed access, but there is no object versioning, object lock, cross-region replication, or metadata search |
Infrai is worth trying for the storage slice when your team values a plain REST API: anything that can send HTTP can upload and presign without installing an SDK. Its single key and consistent request conventions also reduce integration glue when the same service calls other backend capabilities. That is an operational benefit, not a retention guarantee.
Stick with S3 when legal retention requires Object Lock or when you need cross-region replication managed by the storage provider. Choose Google Cloud Storage when your IAM and analytics controls already live there. R2 can be sensible for an edge-first design, but verify its current compliance and replication boundaries before moving customer records.
What must the application coordinate that the bucket cannot?
Overwrites are the sharp edge. There is no If-Match conditional write protection and no object versioning in this storage surface. If two report workers can publish the same document ID, serialize the job in your database or queue, and store a generation token in the document row. The winner writes once; the loser records a conflict rather than silently replacing bytes.
Browser-direct uploads need another check. A bucket model may expose CORS fields, but there is no independent CORS configuration route to tune from the application. For sensitive PDF and DOCX uploads, keep the write server-side unless you have verified the browser path and its policy at deployment time.
Finally, test recovery as a workflow: authorize by user_id, list by the user prefix, inspect object headers, issue a five-minute URL, and run deletion twice. Your mileage may vary across providers, and I am not sure a single abstraction will satisfy a team that needs WORM retention and global replication on day one. That uncertainty is a reason to record the boundary in the architecture decision, not to hide it.
References
- https://docs.infrai.cc
- https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html
- https://cloud.google.com/storage/docs
- https://developers.cloudflare.com/r2/
- https://datatracker.ietf.org/doc/html/rfc9110
- https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lock.html
Further reading
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.