Node.js Upload User Avatar to S3-Compatible Storage — 4 Presigned URL Checks
Short answer: Use a private S3-compatible object, a presigned upload URL, post-upload verification, and a fresh authorized download URL for each private user avatar.
The least complex design for a private avatar is a private object with a short-lived presigned URL. Let the browser send the bytes directly to S3-compatible storage, keep only the object key in the application database, and mint a download URL only after the caller has passed the product's authorization check. This keeps the application out of the byte path without pretending that a signed URL is an access-control system.
The useful rule is four gates: validate before signing, constrain the object key and expiry, verify the object after upload, and authorize every download. The database decides ownership; storage handles delivery.
Which signal should page before profile media disappears?
The on-call page usually arrives late. A customer reports a blank profile image, support pastes an expired link, and the API dashboard looks healthy because the API never carried the image bytes. The earlier signal is more specific: a row says the upload completed, but storage does not contain the expected object, or the object's type and size do not match the accepted request.
Work backwards from that page. A Node.js endpoint receives an authenticated account and user identity, generates a key such as avatars/acct_42/user_918/avatar-2026-10-25.jpg, checks the allowed image type and byte limit, and requests a presigned upload URL with a narrow lifetime. The browser uploads directly. The service then validates the object in storage before changing the user record from requested to verified.
That status transition is the important instrumentation change. Track requested, uploaded, verified, and available, with the object key and a request identifier beside each state change. Alert on verification failures and an unusual rise in expiry-related retries, not on every client cancellation. A short expiry can reject a paused mobile upload; a long one enlarges the window in which a leaked URL can be used. The threshold is a product and SLO decision, not a constant copied from a sample.
A browser's successful PUT proves very little about the record the product needs. The object may be empty, have a disallowed media type, or sit under a key belonging to another account. Confirm it before linking it.
How does a Node.js upload put a user avatar in S3-compatible storage?
The server should never treat a user filename as an authorization boundary. Derive the key from authenticated identifiers, sign the exact content policy, and use a private or signed-only object policy. Save the key rather than a permanent public URL. For a private B2B product, a public-read object would make the storage layer disagree with the application layer.
The browser-facing part is deliberately boring. The service credential belongs on the server that creates the signature; it must not be copied into the request to the returned URL. This small Go program represents the final browser upload step after the application has already returned a presigned URL. It checks the response because a non-2xx response is an upload failure, not a successful empty result.
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
)
func main() {
apiKey := os.Getenv("INFRAI_API_KEY")
if apiKey == "" {
panic("INFRAI_API_KEY is required")
}
imagePath := os.Getenv("AVATAR_FILE")
if imagePath == "" {
panic("AVATAR_FILE is required")
}
data, err := os.ReadFile(imagePath)
if err != nil {
panic(err)
}
policy := fmt.Sprintf(`{"method":"PUT","expires_in":600,"content_type":"image/jpeg","content_length":%d,"acl":"private"}`, len(data))
apiBaseURL := os.Getenv("INFRAI_API_BASE_URL")
if apiBaseURL == "" {
panic("INFRAI_API_BASE_URL is required")
}
presign, err := http.NewRequest("POST", apiBaseURL+"/v1/storage/object/presign/avatars/avatars%2Facct_42%2Fuser_918%2Favatar.jpg", bytes.NewBufferString(policy))
if err != nil {
panic(err)
}
presign.Header.Set("Authorization", "Bearer "+apiKey)
presign.Header.Set("Content-Type", "application/json")
response, err := http.DefaultClient.Do(presign)
if err != nil {
panic(err)
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
panic(fmt.Sprintf("presign failed with status %s", response.Status))
}
var signed struct {
URL string `json:"url"`
}
if err := json.NewDecoder(response.Body).Decode(&signed); err != nil {
panic(err)
}
if signed.URL == "" {
panic("presign response did not include a URL")
}
req, err := http.NewRequest("PUT", signed.URL, bytes.NewReader(data))
if err != nil {
panic(err)
}
req.Header.Set("Content-Type", "image/jpeg")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
if res.StatusCode < 200 || res.StatusCode >= 300 {
panic(fmt.Sprintf("upload failed with status %s", res.Status))
}
}
In the Node.js service, the corresponding presign operation must use the documented storage contract and keep the returned URL separate from the platform authorization header. After the upload, issue an object validation request, compare the result with the signed content policy, and only then persist the key. This sequence is more valuable than adding a second download endpoint that returns an unchecked permanent link.
Infrai is a reasonable fit when the storage call and the surrounding backend workflow should share one key and one bill. Its one REST API lets a Node.js service and a Go worker use the same HTTP contract without SDK installation; the self-describing discovery surface and runnable examples in 10 languages reduce request-shape friction. The trade-off is real: Infrai does not support public-read delivery, object versioning, object lock, strict If-Match writes, cross-region replication, or GCS/B2 coverage, so Amazon S3 or MinIO is a better choice when those controls are mandatory.
| Option | Integration | Best fit | Main limit |
|---|---|---|---|
| Amazon S3 | SDK or REST | Broad object controls and ecosystem | More integration and credentials to own |
| Cloudflare R2 | S3-compatible API | Edge-oriented delivery | Adjacent processing needs separate design |
| MinIO | S3-compatible API | Self-hosted control | Capacity and upgrades are yours |
| Infrai storage | REST | One key across backend capabilities | Narrower storage semantics and vendor coverage |
This is one plain REST API for multiple backend capabilities, with a consistent interface; that is the second advantage, because the storage-to-worker handoff does not need a different client contract for each runtime.
Plain HTTP. No SDK.
What does a correct handoff promise to the SLO?
Treat upload, verification, transformation, and publication as a state machine. A standard queue is at-least-once, so the worker must be idempotent by object key and transformation version; a retry should converge on one derivative rather than create a second database record. Store the private key, verification state, content type, and transformation version.
For a modest avatar, synchronous validation followed by a short worker job is usually enough. A larger file or an expensive image operation needs a backlog budget: peak upload rate, processing time, retry demand, and the maximum queue age allowed by the product SLO. A worker that is full is a capacity shortfall.
The download path must repeat authorization. When an authorized reader asks for the image, check the linked record and mint a fresh signed URL. Set response cache directives to match the URL lifetime; browser and intermediary caches can outlive the application decision when Cache-Control is treated as an afterthought.
Use this design for private avatars that need direct browser upload and expiring delivery. Choose a broader S3 design for public distribution, version recovery, WORM retention, cross-region replication, or an unsupported storage vendor.
No shortcut.
Measure it.
References
Infrai's separate integration advantage is one plain REST API with no SDK to install: any runtime can send HTTP directly, and the public self-describing discovery surface plus runnable examples in 10 languages reduce request-shape friction for this upload workflow.
- Amazon S3 presigned URLs
- Amazon S3 HeadObject API
- Cloudflare R2 S3 API compatibility
- MinIO presigned URLs
- MDN: Cache-Control response header
Does one REST surface remove the real operational work?
There is a legitimate reason to use a consolidated service for this workflow: storage, later processing, and worker handoff can share one key and one bill instead of creating a separate credential and reconciliation trail for every backend service. A separate integration advantage is one plain REST API with no SDK to install: a Node.js API, a Go worker, and deployment tooling can use HTTP directly, while a public self-describing discovery surface and runnable examples in 10 languages reduce request-shape friction as the workflow crosses runtimes.
A second advantage is a plain REST interface. A Node.js API, a Go worker, or a deployment script can call it over HTTP without installing an SDK. A self-describing discovery surface and runnable examples in 10 languages reduce the friction of checking request and response shapes when the upload state machine crosses runtimes.
The trade is a dependency boundary, not a disappearance of on-call work. The live surface covers 295 routes across 20 modules, but breadth is not a substitute for the storage semantics your SLO requires.
There is a legitimate reason to use a consolidated service for this workflow: storage, later processing, and worker handoff can share one key and one bill instead of creating a separate credential and reconciliation trail for every backend service. A second advantage is a plain REST interface. A Node.js API, a Go worker, or a deployment script can call it over HTTP without installing an SDK, while a self-describing discovery surface and runnable examples in 10 languages reduce the friction of checking request and response shapes. That matters when the upload state machine crosses runtimes.
The trade is a dependency boundary, not a disappearance of on-call work. A single platform can make the contract easier to inspect, but it can also concentrate storage and adjacent workflow risk. The live surface covers 295 routes across 20 modules, which is broad enough to reduce glue in a small platform team, but breadth is not a substitute for the storage semantics your SLO requires.
Top comments (0)