Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 6 min read

3 Presigned Browser Storage Upload Modes for Large Reports (Tenant Isolation First)

Short answer: use a presigned single PUT for ordinary generated reports, switch to multipart when retrying the whole file would violate the upload failure budget, and reserve presigned POST for browser forms that need po

Short answer: use a presigned single PUT for ordinary generated reports, switch to multipart when retrying the whole file would violate the upload failure budget, and reserve presigned POST for browser forms that need policy-style field constraints. In every case, let the application choose the object key and bind it to the authenticated tenant.

A marketplace report is private output, not anonymous media. The upload method matters, but the tenant boundary matters more: a successful transfer to another customer's key is still a security failure. I treat signing as a narrowly scoped authorization decision, then treat completion as a separate state transition. That distinction comes from the same lesson as OTP delivery: accepting a request doesn't prove that the intended recipient can safely use the result.

Keep it boring.

Govern write authority as tenant-scoped data access

Begin with the maximum report size the product accepts and a connection profile drawn from supported clients. If one interrupted transfer forces the customer to resend an amount of data the product can reasonably tolerate, a single PUT keeps the state machine small. If that resend is unacceptable, multipart earns its extra bookkeeping because completed parts can be retained while a failed part is retried. The threshold is operational, not a universal file-size number.

Tenant isolation changes the signing contract. The browser may propose a filename, media type, and size, but it must not choose the final object key or tenant prefix. The application derives those values from the authenticated principal, creates an opaque report identifier, and records an expected upload before it signs anything. A download name belongs in response metadata; the Content-Disposition header supports filename and filename*, with the latter able to represent non-ASCII names. That keeps a customer-facing filename separate from the storage key.

Names lie.

I don't trust filenames as identity β€” they are presentation data. Two tenants can both upload quarterly-report.csv; neither should learn that the other exists, and neither name should influence authorization. The minimum useful record has four states: pending, uploading, ready, and expired. It also carries tenant_id, report_id, an opaque object key, expected bytes, expected media type, and a checksum when the chosen path can verify one. A background reconciler may inspect stale non-ready records, but only the application can publish a report to the customer. Storage success alone never grants visibility. This longer-lived record is the join between authorization, transfer, publication, cleanup, and audit, so losing it turns routine retry handling into guesswork about objects that may belong to different customers.

Implement report identity before granting write authority

The important code sits before any storage call. This Python example shows the invariant without tying it to a provider SDK: the authenticated tenant selects the namespace, while the supplied filename is retained only as sanitized display metadata.

from dataclasses import dataclass
from pathlib import PurePath
from uuid import UUID, uuid4


@dataclass(frozen=True)
class UploadIntent:
    report_id: UUID
    tenant_id: UUID
    object_key: str
    download_name: str
    expected_bytes: int


def create_upload_intent(
    authenticated_tenant: UUID, supplied_name: str, expected_bytes: int
) -> UploadIntent:
    if expected_bytes <= 0:
        raise ValueError("expected_bytes must be positive")

    report_id = uuid4()
    safe_name = PurePath(supplied_name).name or "report.bin"
    object_key = f"tenants/{authenticated_tenant}/reports/{report_id}"
    return UploadIntent(
        report_id=report_id,
        tenant_id=authenticated_tenant,
        object_key=object_key,
        download_name=safe_name,
        expected_bytes=expected_bytes,
    )


def may_publish(intent: UploadIntent, authenticated_tenant: UUID) -> bool:
    return intent.tenant_id == authenticated_tenant

This is intentionally scoped to the authorization boundary rather than a storage integration: the adapter still has to create the appropriate signed operation, but it receives a server-derived key instead of browser authority. The application must also compare observed metadata with the intent before moving the record to ready. For multipart, the completion request needs the same tenant and report ownership check as initiation; possession of an upload identifier must never substitute for authentication.

Signing endpoints need the defenses familiar from OTP systems: authentication, tenant-scoped rate limits, idempotency, expiry, and logs that answer who authorized what without retaining credentials. A client retry with the same idempotency key should find the same intent instead of manufacturing extra objects. Abuse controls belong here because direct-to-storage architecture removes application bandwidth, not application responsibility.

How should the browser choose presigned POST, PUT, or multipart?

Mode Appropriate condition Authorization constraint Operational burden
Presigned POST Form-oriented uploads with a fixed policy envelope The signer can constrain submitted fields More form assembly and policy handling in the browser
Presigned PUT A report that can be resent as one unit One signed request maps cleanly to one opaque key An interruption can require resending the file
Presigned multipart Large reports or fragile links where partial retry matters Each part is authorized and completion is explicit Part tracking, abort handling, and finalization logic

PUT is my default because it exposes fewer states to lose. Multipart is the deliberate exception, not an upgrade applied to every file. Its benefit appears only when partial retry has real value; below that point, the extra session, part inventory, and completion step enlarge the recovery surface.

POST has a different strength. A signed policy can express a browser form envelope, which is useful when the accepted fields are part of the authorization decision. It isn't inherently more secure than PUT. The security result depends on what the signer binds, how short the authorization window is, and whether the application verifies completion before publishing the report.

I'm not sure where your crossover belongs without upload-size and retry telemetry. Your mileage may vary. Measure bytes resent, abandoned sessions, completion latency, and customer retries by coarse size band; don't log signed URLs, raw object keys, or customer filenames. The choice should change when the measured failure cost changes, not when a round number looks impressive in a design document.

Evaluate reconciliation before exposing any report

A happy-path browser test proves very little. Exercise an expired signature, a swapped tenant identifier, a reused completion request, a filename containing path separators, a declared size that differs from the stored object, and a multipart session abandoned after several parts. Then verify the negative outcome: no report becomes visible, no cross-tenant metadata leaks, and cleanup can identify the orphan without guessing from a filename.

Test denial, too.

Be precise about error ownership. An authorization rejection should tell the browser to request a fresh signed operation only after the application rechecks the session. A network interruption can retry a PUT or an individual part according to the chosen mode. A metadata mismatch should quarantine the object and fail the report record; repeatedly retrying the same bytes won't repair a broken contract.

There is a catch. Direct browser upload is not suitable when the application must inspect or transform every byte before storage, when clients cannot reliably execute the signing flow, or when compliance requires traffic to traverse an application-controlled processing boundary. In those cases, proxy the upload through that boundary. Stick with a single PUT when files are modest and connections stable; choose multipart only when partial retry offsets its operational cost. POST remains useful when form-policy constraints are the main requirement rather than resumability.

Cost belongs in the review, but not as the opening argument. Compare stored bytes, request classes, data retrieval, abandoned-part retention, and operational cleanup using the provider's current published terms. Pricing pages change, and workload shape decides which line items matter.

Roll out one authorization state at a time

Ship the intent record and tenant-derived key first. Next, enable the single-request path for an internal cohort, watch completion latency and resend volume, and reconcile every accepted object against an intent. Add multipart behind a size-and-reliability rule only after the baseline produces enough evidence to set that rule.

During migration, old and new objects can share the same authorization layer even if their keys differ. The access decision should resolve a report record, confirm tenant ownership, and only then produce the download response. Set Content-Disposition from stored display metadata at that edge, never by exposing the internal key.

Rollback is small: stop issuing new multipart intents while continuing to finish or expire existing sessions. Don't delete evidence needed for reconciliation. Once the outstanding set reaches zero, the simpler PUT path is again the only writer, and the customer-facing report identity remains unchanged.

The decision rule is compact: isolate first, publish only after verification, and pay multipart's complexity only when partial retry protects a measured failure budget.

References

πŸ“° 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.