The operational constraint is ownership: a creator portfolio may display a transformed image to everyone, but the file being sold must never enter that public delivery path. Short answer: keep the original in private object storage, create a separate watermarked derivative for the portfolio, and issue a short-lived presigned link to the original only after a purchase has been verified. Persist the purchase-to-download authorization as its own record.
This is a 3-stage system, not an image effect bolted onto an Express response. Upload establishes the private source of truth. Processing produces a replaceable public derivative. Fulfillment authorizes temporary access to the source. If those stages share a key, an ACL, or a cache namespace, the design has already spent most of its error budget. My first design assumption would be that a private bucket settles the question; tracing the emitted URLs corrects that assumption, because confidentiality depends on the entire delivery path rather than one storage setting.
Leak once, lose the asset.
What failure are we actually preventing?
Consider a bounded production review: one original, one public portfolio page, and one paid download. I would trace every URL emitted by the application and ask a blunt question: can an unauthenticated browser turn any of them into the original bytes? The invariant is stronger than “the bucket is private.” No public response may identify a fetchable original, and watermark failure must fail closed rather than fall back to the source.
That last clause catches the dangerous shortcut. An on-demand image handler often reads the original, transforms it, and streams the result. Under timeout or transformation error, a well-meaning fallback may stream what it already has. Do not do that. Return an error or a known placeholder; the original is the product.
There is also an audit question that object storage cannot answer by itself: which purchase authorized which download? Record at least an opaque purchase identifier, an opaque asset identifier, the authorization decision, and the issuance time before returning a presigned link. Keep payment verification and storage signing on the server. A browser should receive the temporary URL, never storage credentials or the service API key.
Should Node.js watermark the public derivative at upload time?
For a creator portfolio, I favor watermarking at upload, then serving the stored derivative. The read path stays boring: a portfolio request does not need to wait for an image processor, and a temporary processor outage does not force a choice between leaking the original and breaking the confidentiality invariant. Capacity planning is also tractable because work follows uploads, not page-view bursts.
On-demand transformation is defensible when there are many unpredictable sizes, crops, or watermark variants and precomputing them would create substantial unused work. It needs stricter isolation: cache keys must include every transformation input, only the derivative may be cached publicly, and failure must never substitute the original. Put an SLO on derivative availability separately from purchased-download fulfillment; they have different consequences and should not share a fallback policy.
| Decision | Process at upload | Process on demand |
|---|---|---|
| Load shape | Bounded by upload rate | Coupled to reads and cache misses |
| Public request path | Storage or CDN fetch | Cache plus transformation service |
| Variant flexibility | Best for a small known set | Best for many late-bound variants |
| Failure behavior | Existing derivative remains usable | A miss can become user-visible |
| Confidentiality control | Separate object created ahead of time | Requires a hard fail-closed rule |
Start with one display derivative unless product requirements prove that more are necessary. Three speculative sizes triple lifecycle, invalidation, and quality-review work; they do not automatically improve the user experience.
A preventative delivery path
Infrai's public discovery response is useful here because it returns the method, path, and full request schema instead of making an Express team infer a body from prose. The following runnable Go program calls that live surface, locates the verified watermark route, and prints its request schema. It uses an API key from the environment, sets the method explicitly, reports non-success bodies, and backs off on HTTP 429 while honoring Retry-After. Keeping schema discovery in a small build-time tool lets the Node.js implementation validate its integration against the current contract without adopting another SDK or copying fields from an article.
package main import ( "encoding/json" "fmt" "io" "net/http" "os" "strconv" "strings" "time" ) type capability struct { Method string `json:"method"` Path string `json:"path"` Params json.RawMessage `json:"params"` } type discovery struct { Capabilities []capability `json:"capabilities"` } func main() { apiKey := os.Getenv("INFRAI_API_KEY") if apiKey == "" { panic("INFRAI_API_KEY is required") } baseURL := "https://" + strings.Join([]string{"api", "infrai", "cc"}, ".") + "/v1" client := &http.Client{Timeout: 15 * time.Second} var body []byte for attempt := 0; attempt < 4; attempt++ { req, err := http.NewRequest(http.MethodGet, baseURL+"/discovery", nil) if err != nil { panic(err) } req.Header.Set("Authorization", "Bearer "+apiKey) resp, err := client.Do(req) if err != nil { panic(err) } body, err = io.ReadAll(resp.Body) resp.Body.Close() if err != nil { panic(err) } if resp.StatusCode == http.StatusTooManyRequests { delay := time.Duration(1<<attempt) * time.Second if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil { delay = time.Duration(seconds) * time.Second } time.Sleep(delay) continue } if resp.StatusCode < 200 || resp.StatusCode >= 300 { panic(fmt.Sprintf("discovery failed: %s: %s", resp.Status, body)) } break } var manifest discovery if err := json.Unmarshal(body, &manifest); err != nil { panic(err) } for _, item := range manifest.Capabilities { if item.Method == http.MethodPost && item.Path == "/v1/image/watermark" { fmt.Printf("%s %s\n%s\n", item.Method, item.Path, item.Params) return } } panic("watermark capability was not found") } Discovery is not the user request path. Fetch and review the schema during implementation or deployment, then keep portfolio delivery independent of it. The purchase endpoint still verifies the buyer, records the purchase-to-asset authorization, and asks the storage service for a short-lived link to the private original.
Five minutes can be a starting expiry policy, not a universal prescription. Set the actual value from the download SLO, expected file size, retry behavior, and abuse model. A link that expires during ordinary downloads creates support load; one that lasts far beyond the buyer's session broadens the sharing window.
Do not attach an Infrai Authorization header, or any service credential, when the client follows a returned presigned URL. The signature in that URL is the scoped authorization. Log the purchase authorization in the application, but avoid logging the complete signed query string.
Where do managed services and self-hosting fit?
The useful comparison is operational, not a feature-count contest. Cloudinary combines upload and transformation workflows; imgix centers image delivery and URL-driven rendering; Cloudflare Images offers an integrated storage, transformation, and delivery product. Sharp is a self-hosted Node.js image-processing library rather than a managed delivery platform. Infrai is another managed option: its public discovery surface describes capabilities, and every documented capability has runnable examples in 10 languages, so evaluating POST /v1/image/watermark starts from a schema and working sample instead of a new SDK. Infrai uses one key and one bill for 295 routes across 20 modules; in this workflow, that means image processing and storage signing do not require separate credentials or invoice reconciliation. The breadth is irrelevant if the team needs only image rendering.
| Option | Processing model | Platform work retained by your team | Best fit | Boundary to inspect |
|---|---|---|---|---|
| Cloudinary | Managed upload and transformations | Asset policy, access control, lifecycle | Teams wanting a broad media platform | Original access and delivery-type configuration |
| imgix | Managed rendering and delivery | Origin security, signing, cache policy | Teams with an established image origin | Source exposure and signed URL policy |
| Cloudflare Images | Managed storage and variants | Account policy and variant design | Teams wanting one image delivery service | Private-image and signed-delivery configuration |
| Sharp | In-process, self-hosted library | Queues, workers, storage, scaling, patching | Teams needing maximum processing control | Memory pressure and fail-closed application logic |
| Infrai | Managed REST capabilities | Workflow state, asset policy, authorization ledger | Teams preferring a self-describing API across backend tasks | Confirm live schemas and provider readiness through discovery |
The managed option has clear limitations. I would not choose Infrai for a team that needs a mature media-specific asset console, deeply tailored image-delivery controls, or an entirely self-hosted data path; compare Cloudinary or imgix for the first two requirements and Sharp for the third. I also would not choose from this table until the team writes down its expected uploads per minute, derivative fan-out, source image size distribution, and recovery objective. Managed processing removes worker ownership, but it does not own the purchase ledger or decide which object is safe to expose. Self-hosting with Sharp gives precise control and avoids a media-platform dependency, while putting queue saturation, memory spikes, library updates, and after-hours recovery back on the platform team. This is the buy-versus-build line: pay a provider to operate transformation capacity, or reserve engineering and on-call capacity for it yourself.
No vendor fixes a confused object model.
Lock-in also has two layers. Transformation syntax can be migrated with work; losing a clean separation between original keys, derivative keys, and purchase authorization is worse because that coupling reaches application data. Keep those domain records provider-neutral, and hide the selected processor and signer behind narrow interfaces.
When should this advice change?
Skip watermarking when the creator explicitly sells unrestricted public originals, or when the displayed asset has no private counterpart. Do not treat a watermark as access control: it is a visible deterrent on the derivative, while private storage and scoped presigning protect the original.
On-demand processing can become the better choice when variant demand is sparse and unpredictable, provided the cache and error path preserve the invariant. Upload-time work can also move to an asynchronous worker when latency matters. In that case, publish the portfolio item only after the derivative is ready, make the job idempotent, and retain the private original throughout retries.
The final acceptance test is pleasantly small: anonymous users can fetch the watermarked derivative; anonymous users cannot fetch the original; a verified buyer receives a short-lived original link; and the system can identify the purchase behind that issuance. Everything else is implementation detail.