Direct uploads to S3 remove file bytes from the application server’s request path. They do not remove the server’s responsibility for ownership, validation, publication, or cleanup.
A common implementation creates a presigned URL, lets the browser upload, and accepts a completion request containing an object key. That completion request is an assertion from the client. It is not proof that the right object exists, belongs to the caller, satisfies the product’s limits, or is safe to publish.
The missing component is a finalization protocol: a controlled transition from an authorized upload attempt to a usable product asset.
Treat the URL as a capability #
A presigned URL allows a request to exercise permissions represented by its signature within applicable validity constraints. Anyone possessing it may be able to use that capability. AWS describes expiration, credential lifetime, and policy restrictions in its presigned URL guide.
Do not put the full URL in analytics, support logs, or third-party error reports. Treat query-string redaction as part of the upload feature.
The relevant product question is: what exactly can the holder do, to which key, for how long? A URL for one object should not become an excuse to trust arbitrary object names later.
Allocate identity on the server #
When a user requests an upload, create a server-owned record that binds the authenticated principal, tenant, purpose, allowed constraints, and object location.
{
"uploadId": "upload-example-42",
"tenantId": "tenant-7",
"ownerId": "user-12",
"objectKey": "staging/tenant-7/upload-example-42",
"purpose": "project-attachment",
"maximumBytes": 10485760,
"state": "issued"
}These values illustrate an application record. The maximum size is an example policy, not a universal recommendation.
Use an opaque identifier in the completion API. Resolve the object key from the server record rather than accepting a replacement key supplied by the browser. Recheck that the caller can finalize this upload for the intended tenant and parent resource.
A unique key reduces accidental collisions. It does not by itself prove ownership, prevent replay, or make the uploaded contents immutable.
Separate upload success from publication #
Model the lifecycle explicitly:
issued -> uploaded -> verification pending -> accepted -> published
-> rejected
issued or uploaded -> expired -> cleanupThe browser can report that its request completed, but the server owns the authoritative state transitions. A completion request should be idempotent: repeating it returns the existing outcome or advances one valid transition, without creating a second attachment or firing the same business effect twice.
Before acceptance, inspect the expected object using trusted storage APIs. Verify the relevant size, metadata, encryption policy, and object identity. A client-provided MIME type does not establish the file’s actual type. A checksum can establish byte integrity under its documented semantics, but cannot establish that the bytes are safe.
When malware scanning or format validation is required, keep the object in quarantine until that work succeeds. Downloads should resolve only accepted assets.
Close the overwrite window #
AWS documents that a presigned upload to an existing key can replace the object. It also documents conditional writes, including conditions that can prevent overwriting an existing object.
This matters after verification. Suppose the server scans an object, marks it accepted, and then the original upload capability writes different bytes to the same key. If downloads resolve the latest object, the published asset may differ from the scanned asset.
One approach is write-once upload semantics using an appropriate conditional request included in the signed request. Another uses versioning and pins verification and subsequent reads to a specific version. A third publishes a verified copy to a location that the upload capability cannot modify.
Each approach requires end-to-end testing. Pinning a version in the database is ineffective if the download service silently reads the latest version. Copying is ineffective if the source can change and the copy is not bound to the verified identity.
Design finalization as a compare-and-set transition #
A proposed finalizer proceeds as follows:
load upload by ID and authenticate its owner
verify the expected object or pinned version
run required checks and retain their object identity
atomically transition verification-pending -> accepted
create the attachment using a unique upload ID
make downloads resolve the accepted object identityThis is protocol pseudocode. Database uniqueness, transaction boundaries, and storage conditions must be implemented for your stack.
A competing finalizer should observe the winner’s result, not create another attachment. A failed check should preserve the reason without making the object publicly retrievable. If verification is asynchronous, expose pending as a real user-visible state rather than reporting success prematurely.
For the storage race itself, see object-storage concurrency control.
Failure policy #
| Event | Required behavior |
|---|---|
| URL expires before upload begins | Issue a new authorized attempt under product policy |
| Client reports completion but object is absent | Keep the upload unaccepted |
| Object identity changes during verification | Reject or restart verification against the new identity |
| Finalization is repeated | Return the same asset or pending result |
| Scanner is unavailable | Keep quarantine closed; surface delayed processing |
| User loses access before finalization | Recheck authorization and deny publication when required |
Do not assume that hiding a completion button prevents misuse. The server must enforce the protocol for direct API calls and delayed retries.
Cleanup is part of the cost model #
Some users abandon uploads. Some uploads succeed but never finalize. Some objects are rejected and retained for investigation. These states need explicit retention and cleanup policies.
Track bytes in staging, oldest unfinalized object, rejection rate, verification latency, and orphan cleanup failures. Separate legitimate slow uploads from abandoned records using an operational window appropriate to the product.
Multipart uploads add another retention path: incomplete parts need their own lifecycle handling. See the multipart cleanup guide.
What to prove before launch #
Attempt finalization from another tenant. Repeat it concurrently. Replace the object after scanning. Expire the capability. Interrupt verification after the storage check but before the database transition. Confirm that every accepted asset still points to exactly the object identity that passed verification.
The product contract should be simple for the user: upload, wait if necessary, then use the asset. The internal protocol earns that simplicity by treating publication as a verified state transition.