Dev.to Security 🔐 Cybersecurity 👁 0 📖 7 min read

Tenant-Isolated Media Uploads — Choosing Object Storage for Browser-to-Bucket Delivery

The cheapest object storage service is rarely the one with the lowest advertised storage line item. For browser uploads in a B2B SaaS product, the decisive cost is the full path: storage, requests, egress, failed retries

The cheapest object storage service is rarely the one with the lowest advertised storage line item. For browser uploads in a B2B SaaS product, the decisive cost is the full path: storage, requests, egress, failed retries, retention, and the engineering time spent proving that tenant A cannot write into tenant B's namespace. I would use short-lived, object-specific presigned URLs and make tenant isolation a server-side invariant. Then I would compare total usage against the billing model of the candidates, rather than picking a provider from a single price column.

That decision keeps the application out of the media byte path. The browser asks the application for permission, the application authenticates the user and creates a constrained upload intent, and the browser sends the bytes directly to object storage. The application records completion only after it verifies the object identity and tenant boundary.

What does the browser-to-storage flow actually protect?

A presigned URL is a bearer credential. Anyone who obtains it can use it until it expires, subject to the restrictions enforced by the storage service. It is not a replacement for authorization. The authorization decision belongs in the endpoint that issues the URL, before any URL is returned.

That boundary matters.

Here is the small contract I put around that decision. It uses a fake signer so the example can run in a notebook or test suite without pretending that one provider's signing algorithm is universal. The production signer is an adapter for the selected storage API; the policy and object key rules stay the same.

from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from secrets import token_urlsafe

@dataclass(frozen=True)
class UploadIntent:
    tenant_id: str
    object_key: str
    content_type: str
    max_bytes: int
    expires_at: datetime
    upload_url: str


def create_upload_intent(
    *,
    tenant_id: str,
    actor_tenant_id: str,
    filename: str,
    content_type: str,
    size_bytes: int,
    signer,
) -> UploadIntent:
    if tenant_id != actor_tenant_id:
        raise PermissionError("tenant boundary rejected")
    if not filename or "/" in filename or "\\" in filename:
        raise ValueError("filename must be a single path component")
    if size_bytes <= 0 or size_bytes > 5 * 1024 * 1024 * 1024:
        raise ValueError("media size is outside the upload policy")

    object_key = f"tenants/{tenant_id}/uploads/{token_urlsafe(16)}-{filename}"
    expires_at = datetime.now(timezone.utc) + timedelta(minutes=10)
    upload_url = signer.presign_put(
        object_key=object_key,
        content_type=content_type,
        expires_at=expires_at,
    )
    return UploadIntent(
        tenant_id=tenant_id,
        object_key=object_key,
        content_type=content_type,
        max_bytes=size_bytes,
        expires_at=expires_at,
        upload_url=upload_url,
    )


class NotebookSigner:
    def presign_put(self, *, object_key, content_type, expires_at):
        return (
            "https://objects.example.test/upload?"
            f"key={object_key}&expires={int(expires_at.timestamp())}"
        )


intent = create_upload_intent(
    tenant_id="tenant_42",
    actor_tenant_id="tenant_42",
    filename="quarterly-demo.mp4",
    content_type="video/mp4",
    size_bytes=80_000_000,
    signer=NotebookSigner(),
)
print(intent.object_key, intent.expires_at.isoformat())

The example deliberately keeps the filename as untrusted input, generates a server-owned prefix, binds the content type into the signing request, and gives the URL a short lifetime. A real service must also enforce the maximum size at the storage layer or through an upload protocol that can reject an oversized object; a value in an application response is not enforcement by itself. For large media, multipart upload can reduce the impact of a dropped connection, but it adds state, part cleanup, and completion handling.

The completion endpoint deserves equal attention. It should accept an upload identifier, look up the server-created key, check object metadata through a trusted storage API, and transition the media record only when the object belongs to the same tenant and satisfies the expected type and size. Never let the browser choose the final tenant prefix.

Why is tenant isolation the first selection axis?

The key layout is useful for operations, but a naming convention alone is not a security boundary. The storage policy, credentials, or equivalent authorization mechanism must prevent writes and reads across tenant prefixes. In a shared bucket, that usually means the application signer can issue URLs only for keys derived from an authenticated tenant context, while background jobs use a separate identity with a deliberately wider scope.

I once treated the object key as harmless metadata. That was the wrong mental model. It is part of the authorization input, so changing it changes the permission being requested. The correction is straightforward: generate it on the server, persist it with the upload intent, and make every later operation refer to that persisted value.

Keep the browser's permissions narrow. A PUT URL should not quietly become a list, delete, or read credential. Separate upload and download intents, use different lifetimes, and avoid placing long-lived storage credentials in browser code. For media that is not public, downloads should receive their own authorization check and short-lived URL.

Which object storage service fits browser file uploads?

Start with a monthly usage shape, not a provider leaderboard: new media bytes, retained bytes, average object size, upload attempts, multipart parts, downloads, cache hits, egress bytes, and deletion lag. Add a retry factor for mobile and unstable connections. A 5 GB upload that fails after 4 GB is still a user-facing failure and may still create request or temporary-part activity, depending on the upload design.

AWS S3, Google Cloud Storage, and Cloudflare R2 all support signed or presigned upload patterns, but their billing dimensions, regional behavior, lifecycle controls, and egress terms differ. That is the useful comparison boundary. Do not assume that a similar API shape means similar total cost. Read the current pricing and limits for the region and storage class you will actually use, then replay your own usage sample. Prices change; the measurement model should survive that change.

Here is a compact comparison without a price ranking:

Option Browser upload path Useful boundary Main trade-off
AWS S3 SDK or S3-compatible signed request Broad ecosystem and storage controls Many billing and configuration dimensions require careful scoping
Google Cloud Storage Client library or signed URL Signed URL support with cloud-region choices Region, class, and network choices affect the workload model
Cloudflare R2 S3-compatible API and presigned URL Familiar object API for direct uploads Check compatibility, placement, and lifecycle details for the exact workflow

None of these is universally cheapest. A service with a simple request path may still be a poor fit if the required region, retention rule, or download pattern is unavailable. Conversely, a feature-rich service can be unnecessary for a small, private media archive where the team wants fewer operational choices. The right shortlist depends on those boundaries.

For browser caching, set cache behavior intentionally. Private media should not inherit a public cache policy, while immutable public derivatives can use a long freshness lifetime and versioned keys. The HTTP Cache-Control response header controls caching behavior, but it does not grant access to a private object. That distinction prevents a caching tweak from becoming an authorization design.

A practical evaluation harness can generate representative upload intents, vary object sizes, expire URLs, retry parts, and verify that tenant IDs never cross in the resulting key or policy. I keep this test separate from a benchmark. The benchmark answers how fast a path is; the harness answers whether the path is allowed. Both matter, and the second one catches the expensive class of bug.

This pattern has limits.

If the product needs synchronous byte inspection before accepting any data, direct upload changes the workflow: the object must land in quarantine, then pass validation before it becomes available. If regulatory or network requirements force all traffic through a controlled inspection point, bypassing the application is the wrong architecture. Direct upload removes application bandwidth from the path; it does not remove the need for scanning, retention, or deletion controls.

The operational checklist I would ship

Before release, test the same intent with a valid tenant, a different tenant, an expired URL, an altered content type, an oversized body, and a reused object key. Test interrupted multipart sessions and define a cleanup policy for abandoned parts. Record an upload ID, tenant ID, object key, byte count, checksum when available, signer decision, and completion result; redact URLs because a URL can carry bearer authority.

Alert on unusual issuance rates, repeated expiry, completion mismatches, and cross-tenant authorization failures. Keep storage access logs correlated with the application upload ID. The application should be able to answer three questions after an incident: who requested this object, which tenant was authorized, and which storage operation actually occurred?

Finally, make deletion and retention explicit. B2B media often has contractual retention rules, legal holds, or administrator-initiated deletion. Lifecycle automation is helpful, but it is not a substitute for an application record that says why an object should exist. The cheapest design is the one whose isolation, retries, and cleanup behavior remain predictable as usage grows.

The decision rule is simple: choose the storage service whose complete cost and control model fits your measured workload, then preserve a provider-neutral signing boundary in the application. Tenant isolation comes before a small storage-rate difference. A direct browser upload is a delivery pattern; it is not the security model.

Sources

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.