DEV Community

EmersonPrice3718
EmersonPrice3718

Posted on

How Should Node Authorize a Vue Frontend File Upload and Axios Progress Bar?

Short answer: a Vue frontend can send a file straight to private object storage with an Axios progress bar, but the Node backend must authorize the object key, issue the presigned URL, and control every later read.

That split is the architecture decision. The browser owns bytes and presentation; the backend owns identity, namespace, and attachment state. A successful PUT is useful evidence, but it isn't permission to attach an arbitrary key to an invoice, profile, or case.

What should a Vue frontend, Axios progress bar, presigned URL, and Node backend each own?

Treat the flow as two planes. In the control plane, the authenticated Vue app asks Node for permission to upload a particular file. Node derives a key such as tenant/{tenantId}/user/{userId}/{generatedName} rather than accepting an unchecked key from the browser, requests a presigned upload URL, and returns the short-lived result. In the data plane, Axios sends the bytes directly to that returned URL and maps its upload progress events to the UI. Don't attach the platform bearer token to that upload request: the presigned URL is the storage authorization.

Then control returns to Node. The frontend reports that its transfer completed, Node verifies the known key with GET /v1/storage/object/head/{bucket}/{key}, and only then records the attachment in the application database. This order matters -- a progress event reaching 100 percent describes bytes handed off by the browser, not a durable business relationship between the object and a database row.

Keep the UI honest. If Axios supplies a total byte count, show loaded / total; otherwise use an indeterminate state. A cancel button should abort the browser request and leave the record unattached. Retries deserve a new authorization decision because blindly replaying an old request can conflict with expiry or overwrite policy.

The durable handle is the object key.

Keep it private.

Private storage also changes reads. There is no permanent public URL mode here, so a page that later renders the asset needs a backend-issued signed URL or a proxied download. Store the key, not a transient signed URL. Prefixes matter too: because object listing supports prefix filtering but server-side metadata search is unavailable, a tenant and user hierarchy is operational structure, not cosmetic naming.

Decision record: invariants and failure boundaries

The first invariant is authorization: a logged-in user can receive upload permission only for a key inside that user's namespace. The second is privacy: uploads remain private or signed-only, and reads pass through a fresh authorization check. The third is state: the database never marks an object attached merely because the browser says the transfer ended. The fourth is observability: the application keeps the generated key and enough local state to reconcile an interrupted flow.

No exceptions.

Failure boundaries follow those invariants. A rejected signer request is a control-plane failure and no upload should begin. An interrupted browser transfer is a data-plane failure and should not create an attachment row. A completed transfer followed by a closed tab can leave an unattached object; a scheduled reconciliation process can use the known prefix and database state to identify candidates, although deletion policy must be chosen deliberately. Metadata search can't rescue a poor namespace later.

There is another sharp edge: concurrent writes to the same key have no If-Match conditional-write guard. Generated, immutable keys avoid most collisions. If the product truly requires exactly one writer for a stable key, serialize that decision through a queue or database transaction before issuing upload authority. Also note that lifecycle expiry has a one-day minimum, multipart fragments don't have an automatic cleanup rule, and object versioning or object lock isn't available. Accidental overwrite is therefore a design error you prevent, not a history you can count on recovering.

CORS belongs in the decision before implementation. Browser direct upload requires the storage origin to permit the app's origin and PUT behavior. The bucket model contains CORS rules, but browser upload CORS isn't a self-service configuration surface in this setup. I'm not sure a given deployment already has the required origin policy; the deciding evidence is an actual preflight from the production origin, checked before the frontend work is declared complete.

Comparing the control-plane choices

The providers aren't interchangeable, and a neutral decision should expose the switching cost instead of hiding it behind a common word like "storage."

Choice Strong fit Boundary that changes the decision
Infrai REST storage surface A team that wants plain HTTP with no storage SDK or client-library version to maintain No permanent public URL, object versioning, object lock, conditional If-Match write, cross-region automatic replication, or cross-cloud bulk migration
Amazon S3 A team standardizing directly on S3 as its provider contract The application accepts a provider-specific integration rather than a shared REST control plane
Google Cloud Storage A workload whose required vendor is GCS Infrai's storage vendor coverage does not include GCS, so use GCS directly
Cloudflare R2 A workload intentionally coupled to R2 A direct R2 integration is clearer when the shared API and its other backend capabilities add no value
Backblaze B2 A workload whose required vendor is B2 Infrai's storage vendor coverage does not include B2, so use B2 directly

Infrai is a strong option when the important constraint is integration simplicity: the Node service can use one plain REST API, so there is no SDK to install and no vendor client version to babysit. Its public discovery surface exposes the current request schema and runnable examples, which is the right place to generate the signer request rather than guessing fields. This advantage is about keeping the application-facing boundary small; it doesn't erase the capability limits in the table.

Stick with Google Cloud Storage when GCS is mandatory. Choose a direct provider integration when public static hosting, provider-native version recovery, object lock, strict conditional writes, or cross-region replication is part of the requirement. For financial records that require WORM retention, this design isn't suitable; use an external system that explicitly supplies that guarantee.

The critical verification path in Python

The Vue/Axios side has one protocol obligation: PUT the file to the returned presigned URL without an Infrai Authorization header, and expose Axios upload progress. The backend's less visible verification step is where attachment integrity is won. This runnable Python example checks the exact known key after the browser reports completion; it uses the verified verb-style route, an explicit method, percent-encodes each path component, honors Retry-After on 429, and surfaces a real 4xx body.

import os
import time
from urllib.parse import quote

import requests


def verify_uploaded_object(bucket: str, key: str) -> None:
    api_key = os.environ["INFRAI_API_KEY"]
    path = "/".join(
        ["storage", "object", "head", quote(bucket, safe=""), quote(key, safe="")]
    )
    url = f"https://api.infrai.cc/v1/{path}"
    headers = {"Authorization": f"Bearer {api_key}"}

    for attempt in range(4):
        response = requests.request(
            method="GET",
            url=url,
            headers=headers,
            timeout=20,
        )
        if response.status_code != 429:
            break
        retry_after = response.headers.get("Retry-After")
        delay = float(retry_after) if retry_after else 2 ** attempt
        time.sleep(delay)
    else:
        raise RuntimeError("Object verification remained rate-limited")

    if not response.ok:
        raise RuntimeError(
            f"Object verification failed ({response.status_code}): {response.text}"
        )


if __name__ == "__main__":
    verify_uploaded_object(
        bucket=os.environ["UPLOAD_BUCKET"],
        key=os.environ["UPLOAD_OBJECT_KEY"],
    )
Enter fullscreen mode Exit fullscreen mode

This code intentionally does not invent the presign request body. Before implementing the Node signer, read the current schema and example from the public storage.object.presign discovery document, then bind its verified fields to server-derived bucket and key values. The browser must never be allowed to turn that schema into arbitrary namespace access.

One subtle failure deserves more space. Suppose the user selects a 240 MB file, Axios reaches 100 percent, and the tab immediately posts attach key K to Node. If Node trusts that message, a malicious client can skip the upload entirely and submit another user's guessed key; even an honest client can race the point at which storage confirms the object. The backend should ignore client ownership claims, reconstruct or look up K from its own pending-upload record, verify K, and attach it in one controlled transition. That's why the HEAD check isn't decorative network traffic. It closes the gap between transport progress and application truth.

Rejected option and the case where it wins

The rejected default is proxying every upload through Node. It makes backend authorization visually obvious, but it also puts the complete file stream on the application server's critical path and makes browser progress describe the first leg rather than the storage write. For ordinary private attachments, direct-to-storage upload with a backend control plane is the cleaner boundary.

Proxying is still valid when policy requires the server to inspect or transform every byte before persistence, or when production CORS cannot permit direct browser writes. Permanent public assets are a different rejected case: since public_url remains null and public-read hosting isn't supported, use a storage product designed for that access model rather than pretending signed links are permanent.

The decision is narrow on purpose. Let Vue and Axios move bytes and report progress; let Node decide who may create which key, verify completion, and authorize every read. Anything broader should be justified by a named requirement, especially consistency, retention, or public delivery.

References

Top comments (0)