An upload is untrusted input with persistence
A file upload combines parsing, network transfer, storage, and later serving. The name, MIME type, extension, bytes, metadata, and image dimensions can all be attacker-controlled. A successful upload should not mean that the file is safe to open, execute, transform, or share. Start by defining the business need: allowed file families, maximum size, retention, visibility, download audience, and whether the file must be processed asynchronously.
Keep uploads out of the web application process when possible. A direct-to-storage flow with a short-lived signed upload URL reduces server memory use and lets the storage layer enforce size and content-type constraints. The application should authorize the destination key and metadata before issuing the URL. Never let a client choose an arbitrary bucket, path, or access policy.
Validate the bytes, not just the filename
Extensions and Content-Type headers are hints, not proof. Inspect magic bytes or use a trusted parser to confirm the file family. A file named report.pdf can contain a script or malformed data, and a polyglot file can satisfy more than one parser. Validate the expected format with a library that has a patching plan, and reject files that exceed parser limits before expensive processing begins.
Normalize or replace names before storage. Use a generated object key and keep the original display name as metadata after applying length, character, and control-code rules. Do not use a client filename as a filesystem path. Strip path separators, reserved names, and confusing Unicode when displaying. A safe display name is not necessarily a safe storage key.
Bound size, rate, and resource consumption
Enforce maximum bytes at the upload URL, reverse proxy, application, and storage layer where practical. Set limits for request duration, multipart fields, archive expansion, image dimensions, decompression ratio, and number of files. A small compressed archive can expand into millions of files and exhaust a worker. Reject nested archives or process them in a sandbox with a strict budget.
Rate-limit uploads by authenticated account, tenant, IP, and operation cost. Track both attempted and accepted bytes. A user who repeatedly uploads rejected files can still consume bandwidth and scanner capacity. Return a clear error without revealing parser internals, and provide a resumable path only when the storage and cleanup model supports incomplete parts safely.
Scan and transform in an isolated worker
Malware scanning, document conversion, thumbnailing, and text extraction should run outside the request handler with a restricted worker identity. Store the object in a quarantine location until the scan and validation complete. The worker should have no need to write arbitrary application data or call unrelated services. Use a temporary filesystem with size and time limits, drop privileges, and remove extracted files after processing.
A scanner result is part of the file state machine: uploaded, quarantined, scanning, clean, rejected, expired, or failed. Do not serve a file simply because it reached storage. A scanner outage should fail closed for files that are downloadable to other users, while a low-risk internal draft may remain pending. Record scanner version and policy so a later rescan can explain why a file changed state.
Store objects with least privilege
Use private storage for user files and issue short-lived signed download URLs after checking current authorization. Keep public assets in a separate bucket or prefix with an explicit policy. The upload service should not automatically have read access to every object in every tenant. Use generated keys that contain an authorization-safe tenant identifier, and verify the tenant from the session rather than trusting a path segment.
Encrypt in transit and at rest, set retention and lifecycle rules, and record deletion requests. Object metadata can contain personal data and should follow the same retention policy as the file. If a file is copied to a derivative, preserve its owner and visibility state. A download endpoint should set a safe Content-Disposition filename and content type, but never rely on those headers to make an unsafe file executable or harmless.
Serve downloads as carefully as uploads
A stored file becomes an attack surface again when another user's browser opens it. Serve untrusted content from a separate origin when possible, use a restrictive Content-Security-Policy, and set X-Content-Type-Options to nosniff. Avoid inline rendering for formats that can contain active content unless the product explicitly sanitizes them. A download response should not inherit the application's authenticated HTML origin if that would let the file read same-origin data.
Authorization must be checked at download time, not only at upload time. Handle revoked memberships, deleted accounts, expired links, and tenant transfers. Log object ID, actor, result, and policy decision without recording the file contents. If a signed URL is leaked, its short lifetime and scope should limit the impact.
Test malicious and incomplete inputs
Test oversized files, wrong extensions, forged MIME types, polyglots, malformed images, zip bombs, path traversal names, Unicode confusables, truncated uploads, duplicate parts, scanner timeouts, and revoked download links. Verify cleanup after failed uploads and workers. Test that a file from one tenant cannot be downloaded by another and that a clean status cannot be forged through metadata.
A secure upload pipeline is a sequence of bounded decisions: authorize the destination, limit the transfer, validate bytes, quarantine and scan, store privately, and authorize every download. Treat files as untrusted data even after a successful scan, because parsers and browsers evolve. The safest pipeline makes the dangerous operations isolated, observable, and reversible.
Keep deletion and retention first-class
An upload system needs a path for abandoned multipart parts, rejected files, expired quarantine objects, derivatives, and user-requested deletion. Run lifecycle cleanup with a bounded job and report failures. Deleting a database row is not deletion if a storage object, thumbnail, scanner copy, or signed URL remains usable. Track object references so cleanup can be verified.
Review the pipeline whenever a new file type or transformation is added. A format that is safe to store may be unsafe to render, and a new parser may have different limits. Keep the file state machine and download authorization tests current. Security is preserved through the entire lifetime of the object, not only at the moment of upload.
Implementation example
Use a short-lived upload intent and a quarantine object rather than accepting an arbitrary filename into a public bucket. Validate size and declared type at admission, inspect magic bytes and content with a scanner, store under an unguessable key, and issue download authorization only after the object reaches an approved state.
upload.state = quarantine
scan(upload.bytes) -> clean | rejected
serve only when upload.state == cleanVerify and troubleshoot
Test empty, oversized, truncated, polyglot, decompression-bomb, executable, malformed-image, and duplicate uploads. Verify content-type sniffing, storage ACLs, signed URL expiry, scanner failure, quarantine cleanup, and download authorization from another tenant. Log object ID and state transitions without logging file contents or tokens.
Operations and recovery
Define lifecycle cleanup for abandoned parts, rejected objects, scanner copies, derivatives, and user deletion. Bound parser CPU and memory, monitor scan backlog and object age, and keep a restore or purge procedure. If a parser vulnerability appears, stop rendering the affected type, quarantine existing objects, and rotate download credentials as needed.
References and further reading
Use OWASP File Upload Cheat Sheet, the storage provider's signed URL and lifecycle documentation, and the scanner's safe-processing limits. Keep the upload state machine versioned and tested.