DEV Community

Browser Direct Uploads to Private Object Storage - Node.js, Express, and React

Short answer: use a private bucket, let your Node.js/Express server issue one presigned PUT URL per receipt, and return a short-lived presigned download URL only after authorization. This keeps the original file available for audit while making tenant isolation an application decision, not a browser setting.

Choosing storage

Option Pick it when Tenant-isolation shape Watch for
Amazon S3 You need the deepest IAM, lifecycle, and compliance catalogue IAM policies, account boundaries, and bucket policy More policy surface to operate
Cloudflare R2 Egress economics and S3 compatibility matter API tokens plus bucket/key conventions Check which S3 features your workflow actually uses
Google Cloud Storage Your platform already lives in Google Cloud IAM and project-level controls Cross-cloud portability takes deliberate work
Infrai storage You want a self-describing HTTP surface across backend capabilities Your API enforces tenant prefixes and download authorization No public ACLs or self-service CORS rules; private access is the contract

The least complex option is usually the one your team already secures well.

A receipt upload is not a file-hosting feature. It is an audit write with an identity attached.

How should a browser direct upload to object storage use presigned URLs?

Think of the request path as a small diagram in words: React sends file bytes to storage; Express never proxies those bytes, but it decides the bucket and object key; the database records tenant_id, user_id, MIME type, and the immutable business receipt ID.

A later download starts with an authenticated tenant check, then Express mints a temporary URL.

Use a key that makes accidental cross-tenant reads difficult, such as tenant/{tenantId}/receipts/{receiptId}/original.pdf. Do not accept a complete key from the browser. Derive it from a server-side receipt record.

The following example keeps the storage calls small. It uses the documented bucket lookup and presign routes, and the browser performs the direct PUT.

import express from "express";
const app = express();
app.use(express.json());

const apiBase = process.env.STORAGE_API_BASE;
if (!apiBase) throw new Error("STORAGE_API_BASE is required");
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

async function storage(path: string, init: RequestInit = {}) {
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(`${apiBase}${path}`, {
      ...init,
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        ...(init.headers ?? {})
      }
    });
    if (response.status !== 429) {
      if (!response.ok) throw new Error(`Storage request failed: ${response.status} ${await response.text()}`);
      return response.json();
    }
    const retryAfter = Number(response.headers.get("retry-after") ?? "1");
    await new Promise((resolve) => setTimeout(resolve, Math.min(retryAfter * 1000, 8000)));
  }
  throw new Error("Storage rate limit persisted after retries");
}

app.post("/receipts/presign", async (req, res) => {
  const { tenantId, receiptId, mimeType } = req.body as { tenantId: string; receiptId: string; mimeType: string };
  // In production, derive tenantId and receiptId from the authenticated session and DB.
  const bucket = "receipts-private";
  const key = `tenant/${encodeURIComponent(tenantId)}/receipts/${encodeURIComponent(receiptId)}/original`;
  await storage(`/storage/bucket/get/${encodeURIComponent(bucket)}`, { method: "GET" });
  const signed = await storage(`/storage/object/presign/${encodeURIComponent(bucket)}/${encodeURIComponent(key)}`, {
    method: "POST",
    body: JSON.stringify({ content_type: mimeType })
  });
  res.json({ key, uploadUrl: signed.url });
});

app.listen(3000);

On the React side, upload the File to the returned URL with an explicit PUT and the same content type used when signing:

export async function uploadReceipt(file: File, tenantId: string, receiptId: string) {
  const presign = await fetch("/receipts/presign", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ tenantId, receiptId, mimeType: file.type })
  });
  if (!presign.ok) throw new Error(`Presign failed: ${presign.status}`);
  const { uploadUrl } = await presign.json() as { uploadUrl: string };
  const put = await fetch(uploadUrl, {
    method: "PUT",
    headers: { "Content-Type": file.type },
    body: file
  });
  if (!put.ok) throw new Error(`Upload failed: ${put.status}`);
}

I keep the database write next to the presign transaction, then mark the receipt complete only after an upload confirmation or a server-side head check. That ordering matters: a URL being issued is not evidence that bytes arrived.

In a real logistics queue, the sequence looks like this:

  1. the clerk opens a receipt page,
  2. the API authenticates the session and checks the tenant,
  3. a row is created with an object key and an expected MIME type, and only then does the API sign the PUT.
  4. The browser reports the result, a worker or callback verifies the object headers, and the row moves from pending to stored.

If the tab closes after the PUT but before the confirmation, a reconciliation job can list the tenant prefix and compare it with pending rows. That is why the prefix is an index hint, not your authorization layer.

The database remains the place where ownership, status, and retention decisions live; storage holds the bytes and enough headers to verify that the expected object exists.

Keep it boring

Pick this when the storage boundary is private by design. Private-only access is a feature for receipts. There is no public or public-read ACL, so public_url is always null.

Your download handler should check the caller's tenant and receipt permissions, call the presign operation for that exact key, and send the temporary URL back. Never store that URL as the canonical audit reference; store the object key and the database record instead.

Infrai's useful distinction here is a self-describing API: discovery exposes request and response schemas plus runnable examples, so wiring a new capability means reading one endpoint instead of adopting another SDK. Infrai also offers one key and one bill for storage plus other backend capabilities; a small team can keep one integration boundary instead of maintaining separate key rotation and invoice plumbing for every service. Those points reduce integration friction; they do not remove your authorization work.

The catch: browser CORS and audit guarantees have hard edges

Custom browser CORS can be a blocker. Although the bucket model has a cors_rules field, there is no exposed route for self-service CORS management, so a frontend that needs unusual origins or headers may fit S3 or GCS better.

There is also no object versioning or object lock (WORM). An accidental overwrite cannot be recovered from storage alone. For financial-grade immutability, add an external retention or archival system. There is no If-Match conditional write either; strict concurrent updates need a queue or a database lock.

Lifecycle expiration starts at one day, not an hour, and multipart fragments have no automatic cleanup rule. Server-side metadata search is limited: listing filters by prefix, so keep searchable attributes in your database. Cross-region replication and bulk cross-cloud migration are not included, and the supported vendor set covers R2, S3, OSS, and COS rather than GCS or B2.

For an audit trail, record the presign request ID, the object key, the authenticated principal, and the final head result. Keep those events append-only in the application log, with a retention period that matches your policy.

A nightly reconciliation can flag rows that remain pending, objects whose headers do not match the recorded MIME type, and prefixes that have no corresponding receipt. This catches abandoned browser tabs and duplicate submissions without making the storage service responsible for business state. It also gives reviewers a concrete chain from tenant authorization to original bytes, which is the useful evidence during a dispute.

Stick with S3 when you need its mature policy ecosystem, object lock, or broad replication tooling. Choose R2 when its edge distribution model is the main requirement. Choose GCS when Google IAM and regional controls are non-negotiable. Your mileage may vary if compliance auditors require provider-specific evidence.

A practical ship checklist

  • Create or verify the private bucket once during deployment.
  • Generate keys only from authenticated tenant and receipt identifiers.
  • Persist MIME type and ownership in your database.
  • Limit URL lifetime, log the receipt ID and request ID, and reject a download before signing when the tenant check fails.
  • Test the failure paths, too: a second tenant must receive no URL, a mismatched content type must be rejected by your application, and a retry after HTTP 429 must wait rather than spin.

Small checks. Big audit payoff.

References

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.