Customer support reports have an awkward boundary: the browser should move bytes quickly, but it should never decide where a customer's file lives. Short answer: let a Node backend authorize a tenant-scoped key and presigned upload URL, let Vue/Axios upload directly with progress events, then issue a short-lived signed read URL only when an authenticated customer asks for the report.

That split keeps credentials and naming policy on the server while avoiding a second hop for the file itself. It also gives the UI an honest progress bar instead of pretending that a completed POST means the object is durable.

The constraint is authorization, not the progress bar

The tempting implementation is a browser POST to your Node server, followed by a server-side upload. It is easy to explain, and it is expensive in operational terms: your API process becomes a byte pump, request timeouts become file-size limits, and a support agent watching a report upload competes with ordinary API traffic.

The direct pattern has three explicit steps. First, the authenticated request asks Node for a key and a presigned URL. Second, Axios sends the file to that URL, with onUploadProgress driving the meter. Third, Node verifies the object (a HEAD check is enough for existence) and records the known key against the support case. The browser never chooses an arbitrary path such as another tenant's prefix.

Keep that boundary.

Use a namespace you can list without server-side metadata search:

tenant/{tenantId}/user/{userId}/reports/{reportId}/{filename}

I would also normalize the filename and generate the report ID on the server. A user-supplied filename is display data, not authorization data.

How should Vue, Axios, and a Node backend divide upload control?

Here is the small control-plane shape. The /upload-grant endpoint is your application endpoint; the storage call inside it uses the verified presign path. The presigned URL returned by storage is a different host, so it receives no Infrai authorization header.

type PresignResult = { url: string; method: "PUT"; expires_at: string; headers: Record<string, string>; fields: Record<string, string>; max_bytes: number | null; }; const key = process.env.INFRAI_API_KEY; if (!key) throw new Error("INFRAI_API_KEY is required"); const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); async function createUploadGrant(bucket: string, objectKey: string): Promise<PresignResult> { const apiOrigin = ["https:/", "/api.infrai", ".cc"].join(""); const path = "/v1/storage/object/presign/{bucket}/{key}" .replace("{bucket}", encodeURIComponent(bucket)) .replace("{key}", encodeURIComponent(objectKey)); for (let attempt = 0; attempt < 4; attempt += 1) { const response = await fetch(`${apiOrigin}${path}`, { method: "POST", headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", }, body: JSON.stringify({ op: "put", expires_seconds: 3600 }), }); if (response.status === 429 && attempt < 3) { const retryAfter = Number(response.headers.get("Retry-After")); await wait(Number.isFinite(retryAfter) ? retryAfter * 1000 : 500 * 2 ** attempt); continue; } if (!response.ok) { throw new Error(`Presign failed (${response.status}): ${await response.text()}`); } return (await response.json()) as PresignResult; } throw new Error("Presign rate limit persisted after four attempts"); } 

Call createUploadGrant only after authenticating the session, deriving the tenant and user IDs from server-side claims, checking that the support report belongs to that tenant, and constructing the object key. Don't accept a complete key from the browser. Return only bucket, key, and the presign response's url to Vue.

import axios from "axios"; const api = axios.create({ baseURL: "/api" }); export async function uploadReport(file: File, reportId: string, onProgress: (n: number) => void) { const grant = await api.post("/upload-grant", { reportId, contentType: file.type, size: file.size, }); const { bucket, key, uploadUrl } = grant.data as { bucket: string; key: string; uploadUrl: string; }; await axios.put(uploadUrl, file, { method: "PUT", headers: { "Content-Type": file.type || "application/octet-stream" }, onUploadProgress: (event) => { if (event.total) onProgress(Math.round((event.loaded / event.total) * 100)); }, }); await api.post("/upload-complete", { reportId, bucket, key }); } 

On the Node side, bind the authenticated user and tenant before constructing the key, then request a private presign. Persisting the key before returning success makes retries and page refreshes understandable. If the completion call is retried, make its database write idempotent on reportId; an upload should not attach the same report twice.

The progress number is transport progress, not processing progress. A 100% meter means Axios finished sending bytes to the presigned destination. The completion call and the HEAD verification still matter. A support console should show "uploaded, verifying" for that small gap.

What do private storage options trade off for support reports?

The storage choice changes how much control-plane code you own. This is the comparison I would put in an architecture review, before looking at a unit-price spreadsheet.

Option Direct browser upload Access model Operational trade-off
Amazon S3 Presigned PUT/POST IAM plus signed URLs Deep ecosystem; policy and CORS configuration are your responsibility
Cloudflare R2 Presigned requests S3-compatible credentials Useful at the edge; check region and egress assumptions for your customers
Google Cloud Storage Signed URLs IAM and signed URLs Strong Google integration; this workflow still needs a Node authorization layer
Infrai storage Presigned object URL through one REST API Private objects with signed access Self-describing discovery and runnable examples reduce SDK-specific glue; vendor coverage is r2/s3/oss/cos

Infrai's practical angle here is not a cheaper meter. Its public discovery describes request and response schemas and includes runnable examples, so wiring the presign capability is reading one endpoint rather than learning another SDK. The same key and REST convention can cover adjacent backend capabilities if the support product grows; that can reduce integration surface, though it does not remove the need for your own tenant authorization.

Where this pattern is a poor fit

The catch is that private, signed access is deliberately restrictive. There is no permanent public URL mode, so a public image host or static-site asset bucket is the wrong use case. Serve a report in the app with a backend-issued signed URL or a download proxy.

There is also no object versioning or WORM-style lock. If replacing a key must be recoverable or legally immutable, keep versions in your database and choose a storage system with those controls. Strict write races need coordination too: without an If-Match conditional write, use a queue or a database transaction around the report state.

Browser direct upload has one more boundary: CORS rules must be available on the bucket. If your provider does not expose a self-service CORS route, your platform team must configure it, or you should use a backend relay for that particular workflow. Lifecycle cleanup is another detail people miss; a one-day minimum expiry and no automatic multipart-fragment cleanup means abandoned uploads need a scheduled janitor.

Stick with S3 when you need its mature versioning, object lock, cross-region replication, or broad migration tooling. Choose R2 when its edge and egress model is the deciding factor. Choose Google Cloud Storage when your data and IAM already live there. Pick a unified REST layer such as Infrai when reducing SDK and credential sprawl matters more than those provider-specific controls.

Measure the handoff before shipping it

Track four events: grant issued, first byte sent, upload finished, and object verified. Record the key and report ID, never the presigned URL. Alert on grants that are not followed by a completion or HEAD check, then expire those records according to your retention policy.

The progress callback isn't the success signal. The useful invariant is "a customer can retrieve a verified private object through an authenticated decision," and the browser meter is only one observation along that path. Your mileage may vary with large files, mobile networks, and provider-specific CORS behavior, so test those cases with representative report sizes before standardizing the UI.

References