Short answer: Keep generated images in private object storage, put the object key rather than a public URL in application state, and mint a short-lived presigned download URL only after the application authorizes the reader. The deciding constraint isn't upload syntax. It's whether the delivery path can absorb retries, bursts, and expired links without turning the application server into an image proxy.
A Node.js worker can own generation while a small storage adapter owns upload and signing. Treat that adapter as a replaceable boundary, because signatures and request shapes are provider-specific even when the operational model is not.
Keep that boundary.
What should a Node.js AI image service verify before issuing a private object storage presigned download URL?
Start with identity and lifetime, not CORS. A private bucket prevents anonymous reads; it does not decide which authenticated application user may see a particular image. Store an ownership record that binds the application principal to an opaque object key, check that record on every download request, then ask the storage adapter for an expiring URL. Never accept an arbitrary bucket name or object key from the browser and sign it.
The useful application record is small: an internal image ID, tenant or owner ID, opaque object key, media type, byte length, content digest, creation state, and deletion state. The database is the authorization ledger. Object storage holds bytes. Keeping those jobs separate makes a later provider move possible and prevents a leaked key name from becoming an authorization mechanism.
Use a key layout designed for distribution rather than human browsing, such as a tenant-scoped random identifier. Don't embed prompts, email addresses, model output text, or other sensitive metadata in the key. A presigned URL is a bearer credential — anyone holding it can use it until its expiry or until some provider-specific invalidation condition takes effect — so avoid logging its query string. Log the internal image ID, decision, expiry class, and request correlation ID instead.
CORS enters the picture only when browser JavaScript reads from a different origin. MDN describes CORS as an HTTP-header mechanism through which a server permits a browser origin to access a resource. Configure the narrow origin and methods the client needs; don't mistake a permissive CORS response for authorization, and don't assume a failed browser fetch proves the signature is wrong. First separate an HTTP authorization failure from a browser CORS rejection.
A 403 response from the object endpoint belongs in a different diagnostic class from a browser message that reports blocked cross-origin access: capture status and storage request metadata on the server side, while keeping the signed query string out of logs, then inspect the browser's CORS details separately.
Short-lived links reduce the exposure window, but there is no universal correct lifetime. I'm not sure what lifetime fits your workload without the client retry distribution, render size, user geography, and SLO. Measure time from URL issuance to completed transfer, including retry tails, then choose a lifetime that clears the observed tail with deliberate margin. Too short creates expiry churn. Too long expands credential exposure.
Build the capacity model before the adapter
The storage bill and the reliability budget both depend on traffic shape. The AWS S3 pricing page is a useful example of the dimensions a provider may charge for: stored data, requests, retrieval, data transfer, and optional management or analytics features. It is evidence for building a multidimensional model, not a claim that every service uses the same units or rates. Check the current pricing page for the region and storage class you actually select.
Model at least generated bytes, retained bytes over time, write attempts including retries, signed-read requests, transferred bytes, deletion lag, and abandoned generations. Then put the same variables on the SLO sheet. If generation succeeds but upload does not reach a durable state, the user-facing operation isn't complete. If an object exists but its ownership row was never committed, it is an orphan that needs reconciliation rather than immediate exposure.
A capacity-planning sketch can stay symbolic until measurements exist:
- write throughput = generation completion rate times mean image bytes times upload-attempt factor;
- retained capacity = admitted bytes minus bytes removed by lifecycle and explicit deletion;
- read egress = completed downloads times delivered bytes, split by region and cache behavior;
- signing load = authorized link requests plus retries caused by expiry or interrupted downloads.
Bursts matter. AI jobs often finish in clusters because upstream queues drain in batches; planning only from daily averages hides the write concurrency presented to the bucket and the metadata database. Put admission control ahead of generation or upload, cap concurrent transfers, use bounded retries with jitter, and carry an idempotency key across the application record and storage operation. The exact limits must come from load tests and provider quotas.
Averages will lie.
Here is the buy-versus-build decision I would put in a design review:
| Decision | Managed object storage | Self-hosted object storage |
|---|---|---|
| On-call load | Provider operates the storage control plane; the team still owns integration and data policy | Team owns upgrades, repair, capacity, and failure recovery |
| Capacity | Elasticity and quotas are service-specific | Hardware, replication, and headroom are explicit team responsibilities |
| Lock-in | Signing APIs, identity, lifecycle, and billing semantics may differ | Deployment is controlled, but the chosen protocol and implementation still create coupling |
| Cost model | Evaluate storage, operations, retrieval, and transfer | Evaluate hardware or cloud resources plus engineering and on-call time |
| Best fit | Teams that prefer buying storage operations | Teams with a justified control, locality, or existing-operations requirement |
The catch is operational ownership. Self-hosting is not suitable when the team cannot staff capacity management, upgrades, repair, and recovery testing. A managed service is not automatically suitable when residency, connectivity, or a provider-specific signing contract violates a hard requirement. Stick with the option whose failure modes your on-call rotation can detect and repair within the stated objective.
How can one narrow contract isolate storage and signing?
The application boundary needs two capabilities: commit bytes under an application-selected opaque key, and issue a read URL for that exact key with an explicit lifetime. Keep provider request routes, credentials, and signature details inside the adapter. This Go example is intentionally an interface and orchestration layer, so it does not invent a supposedly portable signing endpoint. A Node.js implementation should preserve the same contract and state transitions.
package images
import (
"context"
"crypto/sha256"
"fmt"
"io"
"net/url"
"time"
)
type ObjectStore interface {
Put(ctx context.Context, key, mediaType string, body io.Reader) error
PresignGet(ctx context.Context, key string, lifetime time.Duration) (*url.URL, error)
}
type Ledger interface {
Begin(ctx context.Context, ownerID, imageID, key, mediaType string) error
MarkReady(ctx context.Context, imageID string, size int64, digest [sha256.Size]byte) error
CanRead(ctx context.Context, ownerID, imageID string) (key string, allowed bool, err error)
}
type Service struct {
store ObjectStore
ledger Ledger
}
func (s *Service) Save(
ctx context.Context, ownerID, imageID, key, mediaType string, body io.Reader,
) error {
if err := s.ledger.Begin(ctx, ownerID, imageID, key, mediaType); err != nil {
return fmt.Errorf("begin image record: %w", err)
}
h := sha256.New()
countingBody := &countingReader{r: io.TeeReader(body, h)}
if err := s.store.Put(ctx, key, mediaType, countingBody); err != nil {
return fmt.Errorf("store image: %w", err)
}
var digest [sha256.Size]byte
copy(digest[:], h.Sum(nil))
if err := s.ledger.MarkReady(ctx, imageID, countingBody.n, digest); err != nil {
return fmt.Errorf("commit image record: %w", err)
}
return nil
}
func (s *Service) DownloadURL(
ctx context.Context, ownerID, imageID string, lifetime time.Duration,
) (*url.URL, error) {
key, allowed, err := s.ledger.CanRead(ctx, ownerID, imageID)
if err != nil {
return nil, fmt.Errorf("authorize image: %w", err)
}
if !allowed {
return nil, fmt.Errorf("image access denied")
}
return s.store.PresignGet(ctx, key, lifetime)
}
type countingReader struct {
r io.Reader
n int64
}
func (r *countingReader) Read(p []byte) (int, error) {
n, err := r.r.Read(p)
r.n += int64(n)
return n, err
}
The handler should return the URL only after CanRead succeeds. It shouldn't persist the signed URL as the durable image location, because expiration is part of the credential; persist the object key and mint another URL after a later authorization check. Also validate media type and maximum admitted bytes before or during upload, and cancel the storage request when the generation job or client request is canceled.
There is an awkward distributed-state edge here — bytes and the ledger update cannot generally share one transaction. Make states explicit: pending before upload, ready after the byte count and digest are committed, and deleted only after the deletion workflow completes. A reconciler can compare pending records with storage metadata and apply a documented policy. Avoid retrying a failed upload under a new key unless the ledger records that new key, or cleanup becomes guesswork.
Verify the delivery path and its SLO
Test the contract against an isolated bucket or compatible test service, then run a smaller set of checks against the production-class configuration before rollout. The test matrix should include an authorized read, a denied owner, an unknown image ID, expiry, a canceled upload, a repeated idempotency key, a byte-limit breach, and a browser request from the allowed origin. Inspect browser developer tools for CORS behavior, but verify the underlying response separately so a missing CORS header is not confused with an authorization decision.
A useful service-level indicator is successful authorized downloads divided by valid authorized download attempts, excluding requests the policy intentionally denies. Pair it with upload durability latency from generation completion to the ready state. Track error classes separately: authorization denial, object absence, signature expiry, throttling, client cancellation, and network interruption tell different stories and require different responses. Don't publish the signed query string in traces.
Deployment should begin with shadow metadata or a small traffic slice. Compare object byte counts and digests with ledger values, watch the pending-state age distribution, and alert on orphan growth rather than on a single orphan. The threshold belongs to the error budget and reconciliation interval; a copied number from another system would be fake precision.
Keep the rollback boring. Preserve the old read path while new writes go through the adapter, make the active signer selectable by configuration, and avoid changing key layout and provider in the same release. If verification breaches the rollout criterion, stop new writes through the new path, continue serving already committed objects through their recorded adapter, and reconcile pending records before retrying. Never turn the bucket public as a rollback.
Operational recommendation
Use a private bucket, an authorization ledger, opaque stable keys, and on-demand presigned URLs behind a narrow adapter. Capacity-plan from byte volume, request amplification, transfer, retention, and burst concurrency; test expiration and CORS as separate failure domains; deploy with observable states and a reversible read path.
There isn't a universally best storage operating model. Buy the control plane when reducing storage on-call work matters more than provider coupling; build or self-host only when control or locality requirements justify owning capacity and recovery. In either case, the interface and the SLO should belong to your application, not to a pricing page or a signing SDK.
Top comments (0)