Direct-to-Storage Browser Uploads for Web Apps: Tenant-Safe Marketplace Exports
TL;DR: The simplest direct-to-storage upload is a short-lived, server-issued permission for one object key, followed by a server-side verification step before the image appears in a marketplace export. The browser sends
TL;DR: The simplest direct-to-storage upload is a short-lived, server-issued permission for one object key, followed by a server-side verification step before the image appears in a marketplace export. The browser sends bytes directly to storage; your application still owns tenant identity, object naming, metadata, and the export manifest. That split removes a large request body from the web process without handing the browser a reusable bucket credential.
For a marketplace, tenant isolation is the decision that matters most. A seller uploading chair-42.jpg must not be able to choose another seller's prefix, overwrite an existing object by guessing a key, or make an image appear in an export merely because an upload request succeeded. I design the flow around those three boundaries first. Cost and SDK convenience come later.
How should web apps implement direct-to-storage browser uploads?
The data flow is deliberately narrow. An authenticated seller asks the application for an upload slot. The application derives the tenant from the session, creates an unpredictable object key, records a pending image row, and returns a time-limited upload request. The browser uploads the file to storage, then tells the application which slot finished. The application checks the stored metadata and promotes the row to ready; an export query reads only ready rows for the requesting tenant.
Here is the small Python service boundary I want to test. sign_upload is an adapter around the storage system's signed-request API, so the policy is visible without tying the article to one provider. The example uses an application-owned object key, not a filename supplied by the browser.
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from secrets import token_urlsafe
@dataclass(frozen=True)
class UploadSlot:
tenant_id: str
object_key: str
content_type: str
max_bytes: int
expires_at: datetime
upload_url: str
MAX_IMAGE_BYTES = 10 * 1024 * 1024
ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp"}
def create_upload_slot(*, tenant_id: str, content_type: str,
size: int, storage) -> UploadSlot:
if content_type not in ALLOWED_TYPES:
raise ValueError("unsupported image type")
if size < 1 or size > MAX_IMAGE_BYTES:
raise ValueError("image size is outside the allowed range")
object_key = f"tenants/{tenant_id}/images/{token_urlsafe(24)}"
expires_at = datetime.now(timezone.utc) + timedelta(minutes=10)
upload_url = storage.sign_upload(
key=object_key,
content_type=content_type,
content_length=size,
expires_at=expires_at,
)
return UploadSlot(
tenant_id=tenant_id,
object_key=object_key,
content_type=content_type,
max_bytes=size,
expires_at=expires_at,
upload_url=upload_url,
)
def complete_upload(*, actor_tenant_id: str, slot: UploadSlot,
storage, images):
if actor_tenant_id != slot.tenant_id:
raise PermissionError("tenant mismatch")
if datetime.now(timezone.utc) >= slot.expires_at:
raise ValueError("upload slot expired")
metadata = storage.head(slot.object_key)
if metadata.content_type != slot.content_type:
raise ValueError("stored type does not match the slot")
if metadata.content_length > slot.max_bytes:
raise ValueError("stored object is too large")
images.mark_ready(
tenant_id=actor_tenant_id,
object_key=slot.object_key,
content_type=metadata.content_type,
content_length=metadata.content_length,
)
The application should persist the slot before returning it. In a real service, images.mark_ready is conditional on the pending row belonging to the same tenant and object key. That condition is more important than the exact database method name. It turns completion into an authorization check instead of trusting a client-supplied path.
One boundary. Keep it visible.
No magic.
The useful implementation detail is the pending row. It should contain the tenant ID, product ID, generated object key, expected content type, expected length, expiry, and lifecycle state before the browser sees a URL. That record lets the completion handler compare server-held facts with storage metadata instead of accepting a client claim. It also gives operations a place to find abandoned uploads, and gives tests a stable object to inspect when a cross-tenant attempt fails. For example, if seller A sends seller B's slot ID to the completion endpoint, the handler should load the slot, derive seller A from the authenticated session, compare the IDs, and make no state transition. If a retry repeats a valid completion, the transition should remain idempotent. If the object is present but its headers disagree with the slot, the row should stay pending or become rejected according to the policy you chose. Those are small details, but they are the difference between a byte-transfer demo and a marketplace workflow that can explain every exported image.
I initially thought the signed URL was the feature. Later, the awkward part became the completion transition: an upload can succeed while the application row is still pending, and an export must not guess what that means. That is why the storage response is evidence for a state change, not the state change itself.
The browser-side request is intentionally boring: it uses the method, headers, and URL returned by the application, then sends the file to the signed destination. Do not let the browser manufacture an object key or copy a storage credential from a configuration file.
# Pseudocode for the browser contract, kept in Python to make the
# request/response shape explicit for a Python application team.
upload_slot = api.post(
"/image-upload-slots",
json={"content_type": file.content_type, "size": file.size},
)
storage.put(
upload_slot.upload_url,
body=file.bytes,
headers={"Content-Type": file.content_type},
)
api.post(
"/image-upload-slots/complete",
json={"slot_id": upload_slot.slot_id},
)
The second block is a contract sketch rather than a browser implementation, because browsers execute JavaScript and this article keeps the application logic in Python. The useful part is the sequence: request, upload, complete. A failed final call leaves a pending object that a cleanup job can find by expiry.
Why does tenant isolation belong in the object key?
An object key is not an authorization system, but it gives every later check a concrete value to bind to. I use a server-generated identifier beneath a tenant prefix, then store that complete key in the image row. The export query filters by tenant_id in the database and retrieves only keys from those rows. It never accepts a prefix from an export request.
That distinction catches a common bug: checking that a user is logged in, then trusting tenant_id from JSON. Authentication answers who the caller is. Authorization answers which seller's rows and objects that caller may touch. Keep the latter derived from the session or token claims, and apply it at upload creation, completion, image lookup, deletion, and export creation.
I also keep the original filename as display metadata, never as the storage identity. Names contain slashes, collide, expose user input, and make overwrite behavior harder to reason about. A generated key makes collisions unlikely; the database row remains the source of lifecycle state.
A signed upload must be narrow in both time and capability. Bind the intended content type and exact length when the storage API supports those constraints. Use a short expiry appropriate to the network and file size. The signature authorizes one upload operation; it does not authorize listing a bucket, reading another image, or writing an arbitrary key.
What happens after the upload succeeds?
An HTTP success from storage means the bytes were accepted. It does not mean the image is safe to publish, belongs to the right catalog item, or should be included in an export. The completion path should inspect object metadata, update the pending row transactionally, and enqueue any image validation or derivative work.
For a marketplace export, I would produce a manifest from ready rows, scoped by the actor's tenant, and only then create download permissions for those exact keys. A manifest makes the unit of review explicit: tenant, product identifier, object key, media type, and current lifecycle state. It also gives an evaluator something deterministic to assert.
My test matrix starts with tenant A and tenant B, because happy-path tests miss the boundary that matters. It includes a guessed key, a mismatched slot ID, an expired signature, a changed content type, a duplicate completion call, a deleted pending row, and an export request with an altered tenant field. Each case should fail closed and leave a useful audit event.
The cleanup worker is part of the design, not an afterthought. It deletes expired pending objects after the retention window chosen by the team and marks their rows abandoned. Keep that worker idempotent: a retry after a successful delete should not turn a harmless missing object into a second business action.
How do I choose the storage boundary?
The right comparison is the boundary you need to operate, not a list of feature names. Object storage with signed requests is a good fit when the application wants to own identity and metadata while storage handles large byte transfers. A self-hosted object store can provide a similar S3-compatible boundary, but the team then owns capacity, upgrades, network exposure, and recovery testing. A managed media service may add transformations or delivery workflows, but those features do not replace tenant checks in the application.
The same distinction appears across common choices: Amazon S3 documents presigned URLs for time-limited access; Google Cloud Storage documents signed URLs for specific requests; and Cloudflare R2 documents S3-compatible object storage and presigned URL patterns. Their surrounding operational responsibilities differ, so compare lifecycle controls, audit data, regional behavior, and recovery procedures against your requirements. Do not select a service because its upload demo is short.
| Boundary | Integration shape | Best fit | Main limitation |
|---|---|---|---|
| Managed object storage | Signed request from the application | Teams that want storage to carry bytes while the app owns authorization | Provider-specific lifecycle and regional behavior still need review |
| Self-hosted S3-compatible storage | Signed request behind your own network | Teams that need deployment control and can operate storage | Capacity, upgrades, exposure, and recovery become team responsibilities |
| Managed media service | Service API plus delivery or transformation features | Workflows that genuinely need media processing | Extra features do not replace tenant checks or export manifests |
The table is a decision aid, not a ranking. Direct upload is a poor fit for a tiny form submission where the application must inspect every byte synchronously; sending that small payload through the web process can be easier to reason about. It is also a poor fit when the team cannot operate the required cleanup, audit, and recovery paths. Simplicity has an operational cost.
I would record four decisions before implementation: who signs the request, which exact key and headers it binds, how completion is verified, and how an export proves tenant scope. If a proposal cannot answer all four without a client-side secret, it is not simple; it has moved complexity into a harder-to-audit place.
The notebook-to-prod gap is especially visible here. A notebook can upload one image and print a URL. Production needs an idempotent completion transition, an expiry cleanup path, structured authorization logs, and an evaluation suite that attempts cross-tenant access. I prefer a small interface such as sign_upload, head, and delete behind the service boundary, then run the same policy tests against a local test store and the deployment target. That keeps storage-specific behavior isolated while the security contract stays portable.
Operational checklist in prose
Before shipping, verify that tenant identity is server-derived, object keys are generated server-side, signed requests expire, and the allowed type and size are checked twice: once when issuing the slot and again after storage accepts the object. Confirm that the image row cannot become ready for a different tenant or key. Exercise retries and abandoned uploads. Test exports with two tenants and assert that every emitted object belongs to the requesting tenant's manifest. Finally, log slot creation, completion, rejection, deletion, and export creation with a correlation ID, while keeping filenames and user-controlled metadata out of sensitive logs.
That is the simplest durable pattern I know for this scenario: let storage carry the bytes, let the application carry the authorization decision, and make the export read from verified rows. The shortest code path is not the fewest lines. It is the path with the fewest ambiguous ownership decisions.
Sources
- https://developer.mozilla.org/en-US/docs/Web/API/File
- https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html
- https://cloud.google.com/storage/docs/access-control/signed-urls
- https://developers.cloudflare.com/r2/
- https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.