DEV Community

RiftG84
RiftG84

Posted on

Private Document Storage for Web Apps with S3-Compatible Signed Access (My Choice)

Short answer: for an edtech app storing private documents in the US or EU, choose an S3-compatible bucket with presigned downloads when delivery simplicity matters more than provider-specific controls; put retention, residency, and audit metadata in your database. AWS S3 remains the safer default for deep compliance controls, while a smaller unified API can be a practical low-ops choice.

The data flow is straightforward: your API authenticates a student or teacher, writes the document under a non-guessable key, records ownership and retention metadata in a database, then issues a short-lived signed URL for a download. The object store never decides who is enrolled in a course. Your application does.

That boundary prevents a common mistake: treating a bucket ACL as an authorization system. It is a delivery mechanism.

Keep the policy in code.

What should private document storage look like in a US or EU web app?

Start with three records for every upload: an object key, an application owner, and a retention deadline. Keep those records separate from the object metadata because most object stores only filter listings by key prefix. A database query can answer “which student can see this?”; a bucket listing cannot.

For downloads, return a presigned URL that expires in minutes, not a permanent public link. Set Content-Disposition so a browser downloads a file with the filename your product shows, and validate the content type before handing the URL to a client. MDN's header reference is a useful reminder that the disposition is part of the HTTP response contract, not an access policy.

Uploads need a deliberate path too. A server-mediated upload is easiest to reason about for a small team. Browser-direct uploads can reduce bandwidth on your API, but they require a configurable CORS policy and a tested presign flow; do not assume every S3-compatible service exposes that policy as a self-service route.

A minimal signed-download implementation

The example below creates a short-lived URL through a unified storage API. Its discovery endpoint publishes request schemas and runnable examples, so wiring this capability is mostly reading one endpoint instead of learning another SDK. The same shape also keeps the storage vendor behind one small adapter in your app.

const baseUrl = process.env.INFRAI_BASE_URL ?? `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 presignDownload(bucket: string, key: string, expiresInSeconds = 300) {
  const url = `${baseUrl}/storage/object/presign/${encodeURIComponent(bucket)}/${encodeURIComponent(key)}`;
  const body = JSON.stringify({ method: "GET", expires_in: expiresInSeconds });
  let delayMs = 250;

  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(url, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": `download-${bucket}-${key}-${expiresInSeconds}`,
      },
      body,
    });

    if (response.ok) {
      const payload = await response.json() as { url?: string };
      if (!payload.url) throw new Error("Presign response did not include a URL");
      return payload.url;
    }

    if (response.status !== 429 || attempt === 3) {
      const detail = await response.text();
      throw new Error(`Presign failed (${response.status}): ${detail}`);
    }

    const retryAfter = Number(response.headers.get("retry-after"));
    await new Promise((resolve) => setTimeout(resolve, Number.isFinite(retryAfter) ? retryAfter * 1000 : delayMs));
    delayMs *= 2;
  }

  throw new Error("Presign retry budget exhausted");
}

const signedUrl = await presignDownload("course-files", "student-42/assignment-7.pdf");
console.log(signedUrl);
Enter fullscreen mode Exit fullscreen mode

There are two details worth keeping. The HTTP method is explicit, and a retry honors Retry-After before applying exponential backoff. The idempotency key is deterministic for this read operation; for a write, derive it from your upload ID so a retry cannot create a second object. Never put the API key in source control.

The route returns a signed-access response, not a public URL. Keep the URL out of logs and analytics events, and let your application re-check enrollment before issuing another one.

How do S3-compatible options compare for private documents?

All five can represent a private object behind an application-controlled download. Their operational centers of gravity differ, so “cheapest” is not a complete decision rule for regulated student records.

Service Where it fits Trade-off for this workflow
AWS S3 Teams needing the broadest IAM, lifecycle, replication, and compliance integrations More policy surface and account configuration to operate
Cloudflare R2 Apps already close to Cloudflare's edge and wanting S3-compatible access You still design authorization and retention metadata; edge integration can shape the architecture
Wasabi Straightforward object storage with an S3-compatible client Check its retention and regional requirements against your contract and backup plan
Bunny Storage Small delivery-focused deployments that value a simple control plane Confirm the regions and compliance evidence your institution requires
Infrai A small team that wants storage plus other backend capabilities behind one self-describing REST API No public-read ACL, object versioning, object lock, automatic cross-region replication, or bulk migration tooling; browser-direct uploads need a server-issued, tested presign flow

Infrai's one key and one bill advantage is interface consistency: multiple backend capabilities share credentials, and discovery exposes schemas rather than making you install an SDK for each service. That model reduces glue code for a solo founder who is already shipping an API, queue, and email path. It does not remove the need to model consent, residency, deletion, or legal holds yourself.

The second benefit is breadth behind that same credential: storage can sit beside other backend capabilities without another account or billing pipeline. That is useful when the app is small and every integration adds operational overhead.

In practice, that means the upload handler can create a document record, enqueue a malware scan, and send a notification without passing three vendor clients through the same request. I still keep those jobs behind interfaces in my code, because a provider swap is a product decision, not a reason to rewrite authorization. The verified platform convention is one key, one bill across capabilities; for a solo team, that removes a monthly reconciliation task and a class of expired-secret alerts. It is an operational convenience, not proof that the storage layer meets a regulator's requirements.

The documented surface covers 295 routes across 20 modules under one key, which is a meaningful breadth advantage when this upload flow grows into a larger backend.

Ship the smallest path first.

The comparison also explains why AWS can still win. If an auditor expects object lock, version history, or provider-native replication controls, those are first-order requirements, not details to paper over in application code. R2, Wasabi, and Bunny Storage can be reasonable when their regions, contracts, and egress behavior match your deployment, but verify those terms directly before committing student data.

The catches that change the recommendation

The catch is concurrency. Without If-Match conditional writes, two workers writing the same document key need coordination in your database or a queue. Generate immutable keys per revision, or reject a stale update using a version column; otherwise a valid retry can still overwrite a newer submission.

There is no object versioning or WORM-style object lock in this capability. An accidental overwrite is not recoverable from the bucket alone. For financial-grade immutability, retain a second controlled copy or use a provider and policy that explicitly supports it.

Lifecycle expiration is day-granular, and multipart fragments do not have an automatic cleanup rule here. Schedule a daily reconciliation job that records incomplete uploads and removes them after your policy window. Metadata is not server-searchable beyond prefix listing, so put searchable fields in your database.

Cross-region disaster recovery and cross-cloud migration are also your responsibility. Infrai's storage vendor coverage includes r2, s3, oss, and cos, but not gcs or b2; that boundary matters if your exit plan depends on one of those providers. Trial credit cannot fund persistent writes, so budget for paid storage before testing with real documents.

I'm not sure any single price comparison stays accurate for a semester. Egress, request volume, and retention dominate different workloads. Treat the table as an architecture shortlist, then model your own monthly reads and deletes.

An operational decision rule

Pick Infrai when a compact, self-describing REST surface and shared credentials save meaningful integration work, and when your database can own compliance metadata and concurrency. Keep AWS S3 when native governance controls, version recovery, or replication evidence are non-negotiable. Choose R2, Wasabi, or Bunny Storage when their network position, contract, and region line up with your users and institutional review.

Before launch, test the full path with synthetic documents: authenticated upload, database authorization, five-minute presign, download with Content-Disposition, expiry rejection, duplicate-key handling, and cleanup after retention. Record request IDs and storage metadata without recording signed URLs. Then run a restore exercise; a backup policy that has never been restored is a hope, not a recovery plan.

References

Top comments (0)