To a user, file upload looks simple: select a file, watch a progress bar and see the content appear. To a system, it is one of the most complex inputs it accepts from outside. The file can contain malicious content, an unexpected size or format, sensitive data, or a document that is costly to process.
The core principle is this: a file reaching storage does not make it safe or publishable for the rest of the application. Acceptance, review and publication must be separate states.
Model upload as a state flow
Explicit file states improve security and user experience together. A simple model is:
selected → uploading → received → scanning → approved → published
└→ rejected
└→ failed
received says that the file reached object storage; approved says that it passed security and business rules. Only published is accessible to the intended audience. While a file is scanned, do not issue a download link, and keep “upload complete” distinct from “content ready”.
Every transition must be made by an authorised server-side operation. A field such as scanPassed or public sent from the browser can never decide publication.
Start authorisation with the first request
A user does not always need to send a file through the application server. For large files or higher traffic, the server can issue a short-lived upload permission and the client can upload directly to object storage. That permission must not be a public URL based on a filename.
Before issuing it, the server should check at least: may this user add a file to this resource, is the target area valid, is the expected size in policy, and which content types are allowed? Bind the permission to one user, one server-generated object key, a short lifetime and, where possible, conditions such as content length.
Building object keys from a user-supplied filename is also risky. Predictable names can overwrite another user’s file or leak access paths. Store a server-generated random identifier separately from the original filename, and use a safe download name when the object is displayed.
Store uploads in quarantine
After upload, do not move the file immediately to public storage or to the area used by the application. Write it first to a separate quarantine location.
A quarantine record can hold the uploader, target resource, object key, declared content type, size, checksum and timestamp. Because metadata also contains user input, it must not be interpreted as HTML or a shell command in administrative screens.
Quarantine also simplifies cleanup after a failed scan. A rejected object can be retained briefly for audit and then deleted by a lifecycle rule.
Validate content at more than one layer
A file with a .pdf extension is not necessarily a PDF. Checks should be layered:
- Client checks extension and size for quick feedback.
- The server applies expected type and size policy before issuing upload permission.
- A scanning worker checks signatures or magic bytes.
- When needed, a format-specific parser verifies that the document can really be processed.
None of these layers is a complete security boundary. Client checks are easy to bypass, MIME headers can be forged, and parsers have their own attack surface. Parsing and conversion should therefore run in worker processes isolated from the main application, with bounded resources.
Make scanning asynchronous and idempotent
Virus scanning, content moderation, OCR or conversion can make an upload request slow. Doing this work inside the request creates timeouts and poor experience. In a more resilient flow, the quarantine record places a job on a queue; a worker scans the file and writes its result atomically to the file state.
Because messages can be delivered again, the worker must be idempotent. If the same file is scanned twice, the second run must not create another publication; it should recognise the existing result safely. When a worker crashes or an external scanner has a temporary failure, the state can become failed and controlled retry can follow. On a timeout, keeping the file in quarantine is the safe default.
After scanning, only an approved object should move to durable private storage or receive a new access policy. If moving the object and updating state can become inconsistent, keep a record and repair job that can reconcile the two.
Protect download and preview too
Approval does not end access control. At download time, verify again that the user can view the related resource. Short-lived signed URLs can give an authorised user access to a private object without proxying its bytes through the application server. The URLs still should not be guessable, long-lived or broader than one object.
Browser previews of user content deserve special care. Active formats such as HTML and SVG served from the application’s own domain can increase XSS risk. Serving untrusted content from a separate origin, forcing a download or showing a transformed safe representation can be better choices. Headers such as Content-Disposition, content security policy and nosniff complete the defence.
Report the right state to users and signals to the team
“Upload failed” and “file was uploaded but rejected during security review” are not the same message. Users need to know what happened, whether retry makes sense and when support is needed. When explaining a rejection, avoid details that help an attacker bypass controls.
Track upload success, scan latency, rejection rate, retries, queue age and storage failures. Logs may contain object identifiers and operation results, but never file contents, secret URLs or sensitive document text.
Secure file upload is not one antivirus call. Authorised acceptance, quarantine, layered validation, asynchronous scanning, controlled publication and access checks work together to make content sharing easy without weakening the application boundary.