Short answer: keep the bucket private, bind every object key to a server-validated tenant identifier, and issue a short-lived signed download only after the thumbnail record points to an object that exists.
Resize work belongs behind a queue once CPU or memory becomes a meaningful part of the request SLO. That rule is less exciting than a public image URL, which is exactly why it survives an incident review. A property-management application has an awkward data shape: one property can have many photos, one tenant can have many properties, and a thumbnail is derived data that can be regenerated. The authorization boundary cannot be a filename convention.
The incident pattern: a thumbnail key crosses a tenant boundary
The bounded scenario is familiar. A staff member uploads a large inspection photo for tenant A, and a resize job writes thumbs/320/inspection-7.webp. A second job for tenant B receives the same source filename. If the key is derived from that filename, the later write can replace the earlier object, and a lookup that trusts the key can hand the wrong image to the wrong tenant. The byte store did what it was asked to do; the application made identity optional.
That leak needs only one reused filename.
I treat the first clue as a data-model failure, not an image-processing failure. The database row should contain an opaque media ID, tenant ID, property ID, original object key, variant key, dimensions, content type, and processing state. The object key can include the tenant ID for containment and operations, but the read path must authorize the database row before it constructs a signed URL. A prefix is useful for listing and cleanup; it is not an authorization policy. In an actual review, I would trace the request from session tenant to database lookup, then through the queue payload, worker-generated variant key, head check, and URL issuer; any step that accepts a client-supplied bucket path deserves a failing test, because the bug is not limited to the upload endpoint and can reappear in a retry consumer, an administrative export, or a cleanup job that lacks the same authorization middleware.
A good retry has the same ownership semantics as the first attempt. Generate a key from the media ID and variant, and make the database transition to ready conditional on the job version. Two workers may produce the same derived bytes, but an old worker must not move a newer upload back to ready. Keep the original and variants private.
How should Node.js Sharp jobs protect private object storage?
Sharp is the pixel boundary: the Node.js worker reads an approved source, constrains the output dimensions, and emits a bounded thumbnail buffer. Storage is a separate boundary. The worker should not decide which tenant a job belongs to from an object path supplied by a client; it should load the job by an internal ID and obtain the source key from the database.
The storage adapter can be S3-compatible, but its contract should be narrower than the provider SDK. It needs a private put, a read or head check, a signed GET, and a deletion operation for retention. The application owns authorization and metadata. The provider owns bytes and transport. That division makes a later migration an adapter change instead of a rewrite of tenant policy.
The following Go example shows the control flow around an adapter. It deliberately leaves provider-specific signing inside Store; the important behavior is that the tenant comes from the trusted job record, and the returned URL is created only after the write is confirmed.
package media
import (
"context"
"fmt"
"time"
)
type Job struct {
MediaID string
TenantID string
Source []byte
Variant string
}
type Store interface {
PutPrivate(ctx context.Context, key string, content []byte, contentType string) error
Head(ctx context.Context, key string) error
SignGet(ctx context.Context, key string, ttl time.Duration) (string, error)
}
func Process(ctx context.Context, store Store, job Job, thumbnail []byte) (string, error) {
if job.TenantID == "" || job.MediaID == "" {
return "", fmt.Errorf("missing trusted media identity")
}
key := fmt.Sprintf("tenants/%s/media/%s/%s.webp",
job.TenantID, job.MediaID, job.Variant)
if err := store.PutPrivate(ctx, key, thumbnail, "image/webp"); err != nil {
return "", err
}
if err := store.Head(ctx, key); err != nil {
return "", err
}
return store.SignGet(ctx, key, 5*time.Minute)
}
In the real Node.js worker, the equivalent image step is the Sharp resize operation; the storage adapter should receive its output as bytes, never a client-provided destination. Validate the decoded image dimensions and enforce a maximum input size before queueing. A five-minute URL is a policy choice, not a security substitute: revocation is difficult after issuance, so an authenticated endpoint should perform the tenant check every time it mints one.
The architecture I would capacity-plan
Separate the upload request, the resize queue, and image delivery. The request stores the original and a pending row, then acknowledges the job. A worker consumes jobs with a bounded concurrency setting. The read endpoint checks the caller's tenant membership, reads the variant key from the row, and asks the storage adapter to sign it. The browser receives no bearer credential for the storage control plane.
There are three useful SLOs here, not one: upload acknowledgement latency, time from accepted job to thumbnail-ready, and signed-download success rate. Queue age exposes CPU pressure earlier than average request latency. Track bytes per original, variants per photo, resize duration by input size, rejected dimensions, and the count of rows marked ready without a successful head check. The last metric should remain zero.
Capacity planning is mostly multiplication. Estimate photos per property, properties per tenant, average original bytes, variant count, and replacement rate. Then reserve worker throughput for the largest expected upload burst, not the daily average. A 12 MB phone image and a 200 KB image are different CPU and memory jobs even if they count as one upload.
The buy-vs-build decision is operational, not ideological:
| Choice | Strength | Cost or limitation |
|---|---|---|
| Managed object storage | Durable transport, lifecycle controls, and a familiar compatibility layer | Policy surface, egress rules, and provider-specific behavior still need testing |
| Self-hosted object storage | Control over placement, network path, and data handling | The team owns disks, repair, upgrades, monitoring, and recovery drills |
| A thin storage abstraction | Keeps tenant policy and image workflow in application code | It cannot erase differences in lifecycle, replication, signing, or recovery semantics |
The table does not choose for you. For a small platform team, on-call capacity and recovery objectives usually matter more than the first month of storage spend.
Where this design should not be used
The catch is that private signed delivery is a poor fit for permanent public assets, anonymous hotlinking, or a requirement for cross-region object replication with a defined recovery point. Use a platform whose documented controls meet those requirements, and keep the same application-level tenant authorization. A signed URL is also unsuitable as the only audit record: log the actor, tenant, media ID, variant, issuance time, and request outcome in the application.
Do not assume S3 compatibility means identical lifecycle behavior. Verify expiration rules, incomplete multipart-upload cleanup, versioning, retention, and restore semantics in a staging account. The AWS lifecycle documentation is a useful reference for the shape of those policies, while a provider's own contract determines what actually happens. I'm not sure any compatibility label can answer the recovery question for your workload; a restore test can.
A public bucket may look simpler, but it moves a privacy decision into URLs and caches. That is not an acceptable trade for tenant-scoped inspection photos. If the product requirement changes to public marketing imagery, use a separate public asset path rather than weakening the private path.
A rollout that leaves evidence
Start with two tenants and deliberately reuse source filenames. Assert that each database row resolves only to its own prefix, that a signed URL expires, and that a retry cannot publish an older job version. Add a test for a deleted source, a malformed image, and an output that exceeds the configured pixel limit.
At deployment, watch queue age, worker memory, upload acknowledgement latency, and signed-download failures. Configure lifecycle rules for abandoned originals and incomplete uploads only after confirming the provider's semantics, and reconcile database rows against object prefixes on a schedule. The useful outcome is not a clever key format. It is a traceable chain from authorized tenant to immutable media ID to verified private object.
Top comments (0)