1. Separate authorization, transfer, and publishing#
A direct upload keeps your application server from relaying every byte. The browser asks for permission, the backend authorizes one specific operation, and the client transfers the file straight to storage. Afterward, the application inspects the object and decides whether it can be used. That last step is part of the flow, not an optional improvement.
Define explicit states such as pending, uploaded, scanning, ready, and rejected. A row marked ready must represent a validated object, not just a successful response from the browser. The interface can show progress without promising availability before verification is complete.
2. Sign a key the server created#
Validate the session and the user's permission to attach files to the target resource. Build the object key from an authorized tenant and a random identifier generated on the server. The original file name is display metadata only; it must not decide the bucket or allow overwriting an arbitrary object.
session = requireVerifiedSession(request)
resource = authorizeAttachment(session, resourceId)
uploadId = randomId()
key = "quarantine/" + resource.tenantId + "/" + uploadId
recordPendingUpload(uploadId, resource, key, expectedSize)
url = signPut(privateBucket, key, shortExpiry, signedHeaders)
return { uploadId, url, requiredHeaders: signedHeaders }A presigned URL works like a temporary capability: anyone who holds it can perform the permitted operation. Do not log the full URL in analytics or application logs. Pick an expiration that fits the file size, and plan for renewing the permission after re-authorizing the user. The credentials used to sign the URL can expire before the expiration time you requested.
Documentation: AWS: using presigned URLs and their expiration ↗
3. Define limits that are actually enforced#
A Content-Type sent by the client does not prove the file's format. Combine an allowlist of types and extensions with content inspection, a maximum size, and format-specific validation. For compressed files, also consider the expanded size and the CPU cost of processing them.
How you restrict an upload depends on whether you use a presigned PUT, a POST with a policy, and what your S3-compatible provider supports. Do not assume every S3 implementation enforces the same conditions. Check which headers are part of the signature and which limits the storage service validates; cover whatever is missing with a later check on a private object.
- Set a quota per user or tenant and a limit on pending uploads.
- Restrict CORS to your application's origins and methods; CORS is not a substitute for authorization.
- Keep the bucket private and avoid serving uploaded files from your application's own origin without appropriate controls.
Documentation: OWASP: File Upload Cheat Sheet ↗
4. Check what actually reached storage#
The completion endpoint receives uploadId and resolves the object key from the pending record. Check the session, ownership, and state again. Read the object's metadata to compare its size and any other constraints you can verify; do not accept an ETag sent by the client as proof, and do not treat an ETag as a universal hash of the file.
Inspect and scan the content while it is in quarantine, before making it accessible. A PUT URL can be reused for as long as it is valid, so validating a mutable key and then serving that same key leaves a window for the file to be swapped. Bind processing to a specific object version when the provider supports it, or create a private processing copy that the client cannot overwrite. Validate that copy and publish exactly that object.
| State | What the application can do |
|---|---|
| pending / uploaded | Show progress and allow verification; do not serve the content. |
| scanning | Process a version or copy that the client cannot modify. |
| ready | Authorize reads of the exact object that was validated. |
| rejected / expired | Report the cause you are allowed to share and schedule cleanup. |
Make completion idempotent. A second call for the same, already validated object should return the existing state. If the object's identity has changed or the permission has been revoked, stop the flow.
5. Authorize downloads too#
When reading a private attachment, resolve how it relates to the parent resource and verify the user's current access. A hard-to-guess path is not a permission. You can hand out a temporary read URL or serve the file through a component that enforces authorization; choose based on file size, how quickly access must be revocable, and observability.
Set Content-Disposition and a sanitized download file name. For active formats, consider a separate origin and avoid rendering arbitrary content inside your application. Review how caching and shared links affect access when a user leaves a workspace. A link that has already been issued can keep working until it expires.
6. Budget for cleanup and recovery#
Abandoned uploads take up space even if nobody ever clicks save. Schedule cleanup of expired records and orphaned objects with a margin that respects transfers still in progress. If you use multipart uploads, plan for aborting incomplete ones. Keep enough traceability to tell an abandoned upload apart from a provider failure.
Estimate average storage, operations, data transfer, processing, and version retention. The comparison tool normalizes reference prices, but some plans have monthly minimums and others bill operation classes differently. Work through a scenario with your average file size, downloads per file, and deletion policy.
- Test an expired session, a revoked permission, and an upload from another tenant.
- Test a wrong size, a disguised file format, and a repeated completion call.
- Test an overwrite after validation has started and a scanner crash.
- Verify that rejected and pending objects are never public.
Documentation: Cloudflare R2: operation classes and pricing ↗ · DigitalOcean Spaces: minimums and usage ↗
Sources and scope
Documentation checked on September 25, 2026. Examples and decision criteria are editorial proposals; adapt them to your application's contract and validate them in an authorized test environment.
Compare object storage
Review pricing, limits, conditions and sources for each option (in Spanish).
Open comparison