Short answer: choose object storage for the original and derived avatar objects, then put resizing in a separate worker or image service. The storage service alone is usually the wrong place to encode tenant policy. For a property-management application, the invariant is simple: a request authenticated for tenant A must never be able to read, overwrite, or transform tenant B's avatar.
The useful design is a private original, deterministic derivative keys, and a serving layer that checks authorization before it returns a signed or proxied object. The resize operation can be backed by S3, Google Cloud Storage, or Azure Blob Storage; those products differ in surrounding triggers, identity controls, and processing integrations, not in the authorization rule that the application must enforce.
That boundary matters.
Keep it private.
What should the storage layer actually guarantee?
Treat avatar resizing as a pipeline with explicit boundaries. The upload endpoint authenticates the customer and resolves a tenant ID from server-side identity. It accepts an image under a bounded size and format policy, writes the original to a tenant-scoped key, and records the object key in a database row. A worker reads that object, validates its content, creates a derivative, and writes the derivative to another tenant-scoped key.
The important guarantees are ownership, immutability of the original, and idempotent derivation. A key such as tenants/42/avatars/user-7/original makes accidental mixing harder, but naming is not authorization. The bucket or container must remain private, and every read path must derive the tenant from authenticated context rather than accepting an arbitrary path supplied by the browser. This distinction is easy to lose in a small upload feature: a database row can say that user 7 belongs to tenant 42, while a later download handler still trusts a browser-provided key. The result is a cross-tenant read even though the data model looks correctly scoped. The check has to happen at the point where the object is authorized and delivered.
Multipart upload is useful for larger files because it separates an upload into parts and allows the upload to be completed after those parts are stored. It also creates cleanup work: incomplete uploads need a lifecycle policy or an explicit abort path, and the application should not publish an avatar until completion and validation succeed.
What object storage services support image resizing for avatars?
There are three reasonable shapes. A worker with a standard image library is the easiest to reason about and test. A managed image transformation service reduces operational work but introduces another policy boundary. A storage-triggered function shortens the path from upload to derivative, while making retries, permissions, and event duplication more important.
| Approach | Strength | Failure boundary | Decision criterion |
|---|---|---|---|
| Queue plus worker | Explicit retries and reproducible transforms | Queue delay or poison input | Best when auditability matters |
| Managed transformation | Less image-runtime maintenance | Vendor-specific limits and policy | Best when integration is already governed |
| Storage event function | Low-latency start | Duplicate or out-of-order events | Best for small, well-bounded jobs |
I would use the first shape for tenant-sensitive reports and avatars. The extra queue is not ornamental: it lets the upload request finish without waiting for a CPU-heavy transform, and it gives operations a place to quarantine malformed files instead of retrying them forever.
A managed transformation service is a poor fit when the application needs a portable image pipeline, custom validation, or a detailed audit trail that spans upload, transform, and delivery. A worker is a poor fit for a team that cannot patch its image runtime or operate a retry queue. The limitation of a storage-only design is that it stores bytes but does not, by itself, establish tenant authorization or a safe image-processing policy. Those are real boundaries, not reasons to rank one category above another.
How does the critical path enforce tenant isolation?
Keep the browser away from arbitrary object keys. The API creates the key and passes only a short-lived upload capability scoped to that object, or accepts the bytes itself when that is the simpler security boundary. The following Python sketch shows the policy decisions around a generic object-store client; the client methods are intentionally abstract so the access rules remain portable.
from dataclasses import dataclass
@dataclass(frozen=True)
class AvatarObject:
tenant_id: str
user_id: str
version: str
def object_key(obj: AvatarObject, variant: str) -> str:
allowed = {"original", "small", "medium"}
if variant not in allowed:
raise ValueError("unsupported avatar variant")
return f"tenants/{obj.tenant_id}/avatars/{obj.user_id}/{obj.version}/{variant}"
def publish_avatar(store, image_bytes: bytes, obj: AvatarObject) -> str:
if len(image_bytes) > 10 * 1024 * 1024:
raise ValueError("avatar is too large")
original = object_key(obj, "original")
store.put_private(original, image_bytes, content_type="image/*")
enqueue_resize(tenant_id=obj.tenant_id, source_key=original)
return original
The worker must carry the tenant ID as authenticated job data, not rediscover it from an untrusted filename. Before writing a derivative, it should verify that the source belongs to that tenant, reject unsupported or decompression-bomb inputs, and use a deterministic version in the key. A retry then replaces the same derivative or safely observes that it already exists.
Do not treat image/* as content validation; it is metadata. Decode the bytes with an image parser, apply pixel and memory limits, normalize orientation, and strip metadata that the application does not need. The exact limits belong in configuration and tests because they depend on the worker's memory budget. A 10 MB byte limit alone does not cap decoded pixels.
What did I reject, and when is it valid?
I rejected resizing synchronously inside the authenticated upload request. It looks compact, but it couples user-facing latency to image decoding and makes a client retry capable of repeating expensive work. It is valid for a tightly bounded thumbnail with a measured upper limit, a small deployment, and no requirement for independent retry or audit behavior. That is a narrow case, not a default architecture.
I also rejected public derivative URLs as the authorization model. A random-looking URL is a locator, not proof of tenant membership. Use a short-lived capability only when its scope and expiry are explicit, and use an application proxy when revocation, audit logging, or per-request tenant checks matter more than direct delivery.
The decision rule is therefore: pick the object store that matches your identity, event, and lifecycle controls; keep originals private; make transformations asynchronous and deterministic; and test cross-tenant reads and writes as first-class failures. The service name matters less than those boundaries.
References
- AWS, “Multipart upload overview”: https://docs.aws.amazon.com/AmazonS3/latest/userguide/mpuoverview.html
- Google Cloud, “Cloud Storage documentation”: https://cloud.google.com/storage/docs
- Microsoft, “Azure Blob Storage documentation”: https://learn.microsoft.com/en-us/azure/storage/blobs/
- OWASP, “File Upload Cheat Sheet”: https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html
- Python Imaging Library documentation: https://pillow.readthedocs.io/en/stable/
Top comments (0)