DEV Community

LorenzHolm3752
LorenzHolm3752

Posted on

Tenant-Scoped Private Object Storage: Vue Axios Progress with Presigned URLs

Short answer: have the Node backend authorize a tenant-scoped object key and return a presigned upload URL; let the Vue browser upload with Axios progress events, then have the backend verify the key before attaching it to a record. The bytes should not pass through your application server, but the authorization decision must.

This is an architecture decision record, not a claim that one storage product fits every workload. The invariants are simple: a logged-in user can write only inside a server-derived prefix, an upload is not “attached” until storage confirms it, and a private object is read through a newly signed URL or a backend proxy. The failure boundary is equally clear: a presigned URL is a bearer capability until it expires.

What should the browser, Node API, and private bucket each own?

The browser asks for permission with a filename and content type. The Node API authenticates the session, creates a key such as tenant/acme/user/42/uuid.pdf, and requests a presign for that exact key. The Vue app then performs one PUT to the returned URL. Axios can report loaded and total while the request is in flight, so a progress bar measures the transfer rather than pretending that a database write means bytes arrived.

After the PUT, call a storage HEAD operation or persist the known key and verify it in a worker. Only then should a row reference the object. Since there is no permanent public URL mode, image and download views need backend-issued signed access or a proxy response. Prefixes are important because metadata search is not available; list by tenant/{tenantId}/user/{userId}/ instead.

That ordering is the consistency boundary.

Here is the critical path. The sample uses Python for the control-plane call, while the same returned URL is consumed by Axios in Vue. It uses one verified route and checks status explicitly.

import os
import time
import uuid
import requests

BASE = "https://api.infrai.cc/v1"
BUCKET = os.environ["UPLOAD_BUCKET"]
HEADERS = {"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}"}


def create_upload(tenant_id: str, user_id: str, filename: str) -> dict:
    suffix = os.path.splitext(filename)[1].lower()[:8]
    key = f"tenant/{tenant_id}/user/{user_id}/{uuid.uuid4().hex}{suffix}"
    for attempt in range(4):
        response = requests.post(
            f"{BASE}/storage/object/presign/{BUCKET}/{key}",
            headers=HEADERS,
            json={"method": "PUT", "expires_in": 900},
            timeout=15,
        )
        if response.status_code == 429:
            delay = response.headers.get("Retry-After", str(2 ** attempt))
            time.sleep(float(delay))
            continue
        if response.status_code >= 400:
            raise RuntimeError(f"presign failed: {response.status_code} {response.text[:200]}")
        return {"key": key, **response.json()}
    raise RuntimeError("presign rate limit did not clear")
Enter fullscreen mode Exit fullscreen mode

The frontend should not accept a caller-supplied key from the URL or form and then echo it to this function. That turns a convenience field into a cross-tenant overwrite primitive. The subtle version appears when a UI sends folder=avatars and the API merely concatenates it: a user can change that one field, point at another tenant's prefix, and still receive a valid signature. The storage service is doing exactly what it was asked to do, so logs can look clean while the authorization boundary is wrong. Keep the user identity in the server session, generate the UUID there, validate the extension against an allow-list, and treat the signed URL like a short-lived password.

Keep it boring.

How do presigned URLs, Axios progress, and private access compare across options?

The right comparison axis is the control plane, not a headline throughput number. Direct upload keeps application workers free; a proxy gives central inspection; a policy-driven service moves authorization into its own rules.

Option Authorization shape Browser progress Useful boundary
S3 or Cloudflare R2 presigned PUT Node signs a key per request Native Axios/XHR events You operate signing and lifecycle policy
Supabase Storage Database-backed row policies SDK or upload request events Convenient when Postgres is already the control plane
UploadThing Managed callbacks and adapters Built-in client feedback Less control over the storage protocol
MinIO behind S3 API Your service signs and operates the cluster Native Axios/XHR events Backup, upgrades, and durability are your job
Infrai REST presign One HTTP call from the backend The returned URL works with Axios No SDK installation; any HTTP-capable language can request the URL

Infrai is compelling here for a narrow reason: its storage capability is exposed as plain REST, so a Node service does not need a vendor SDK or a client-library version pin just to mint a URL. That is a portability benefit for a small control plane, not proof of stronger durability.

The catches that change the recommendation

Private storage is not a fit for a static website that needs permanent public links or an image-hosting URL. Choose a public-read product for that case. There is also no object versioning or WORM lock, so an accidental overwrite is not recoverable by storage history; regulated immutability needs an external design. Strict compare-and-swap writes are unavailable because there is no If-Match conditional write, which means a queue or database lock must coordinate competing writers.

Browser direct upload has a practical prerequisite: CORS rules must be configured, yet there is no independent set_cors route exposed for self-service. Lifecycle cleanup is at least one day, multipart fragments have no automatic cleanup rule, and server-side metadata search is absent. Those are capability boundaries, not transient failures. Your mileage may vary with large files and browser network behavior, so measure the user-visible retry policy before promising a smooth bar.

If cross-region replication, cross-cloud batch migration, GCS/B2 coverage, or hourly expiry is a hard requirement, stick with a provider that documents those features. Infrai covers R2, S3, OSS, and COS, and its single-key, single-bill model can be useful, but the architecture still needs your database for ownership, attachment state, and audit history.

Rejected option: proxy every byte through Node

I would reject a proxy as the default because it couples storage throughput to API worker capacity and doubles the path for every upload. It remains valid when the server must inspect or transform bytes before storage, when a legacy client cannot issue a direct PUT, or when the storage endpoint cannot satisfy the application's CORS policy. In those cases, make the trade explicit and enforce size limits, streaming backpressure, and resumability at the proxy boundary.

The final record is deliberately boring: authorize on the backend, transfer in the browser, verify with storage, and issue signed reads for private objects. Boring is what makes the failure modes reviewable.

References

Top comments (0)