File upload features carry a disproportionate share of risk relative to how simple they look, because they hand a remote user the ability to place arbitrary bytes into your infrastructure and then, often, get your server or another user's browser to do something with those bytes. Getting this right requires treating every stage — accepting the file, storing it, and serving it back — as its own decision point, because a single gap in any one of them can undo the care taken in the others.

Validate content, not just the filename or extension

Trusting a file's claimed extension or its Content-Type header is trusting the client, and the client is exactly what you can't trust here. Both are trivially set to whatever an attacker wants regardless of the file's actual content. Validate by inspecting the file's actual content — checking magic bytes against the expected format, or better, using a library that fully parses the file to confirm it's structurally valid for its claimed type, not just superficially similar.

python
import magic  # python-magic, wraps libmagic

def validate_upload(file_bytes, allowed_types):
    detected_type = magic.from_buffer(file_bytes, mime=True)
    if detected_type not in allowed_types:
        raise ValueError(f"Rejected: detected type {detected_type}")
    return detected_type

Enforce a strict allowlist of accepted types rather than a blocklist of rejected ones — a blocklist has to anticipate every dangerous format in advance, while an allowlist only has to name what the feature actually needs to support. Set a firm maximum file size before any processing begins, since an unbounded upload is a straightforward denial-of-service vector against disk space, memory, or processing time.

Storage decisions that limit blast radius

Never store uploaded files inside the web root or any path your application server would execute code from — this single decision eliminates the entire category of attack where an uploaded file gets interpreted and run as a script by the server. Generate a new, random filename on storage rather than trusting the client's original one, which also sidesteps path traversal attempts hidden in a crafted filename and collisions between users uploading files with the same name.

Object storage services like S3 or an equivalent, kept entirely outside the application server's execution path, are generally a safer default than storing files on the same disk the application runs from — it structurally separates "a file exists" from "a file can be executed."

Serving files back without creating new risk

Files served back to users need their own content-type handling, independent of whatever was inferred at upload time — set the Content-Type header explicitly based on your validated, allowlisted type rather than re-trusting anything client-supplied, and pair it with X-Content-Type-Options: nosniff so the browser can't override your declared type based on content sniffing. For any file type that could plausibly contain active content — SVGs with embedded scripts are a classic case that looks like a harmless image format but isn't — serve it with Content-Disposition: attachment to force a download rather than inline rendering, or better, strip active content out during processing before it's ever stored.

Serving user-uploaded content from a separate domain or subdomain than your main application is worth the setup cost specifically because it isolates any content that does slip through from your primary site's cookies and session context, limiting what a successful attack through that path could actually reach.

Scan, but don't rely on scanning alone

Running uploaded files through malware and antivirus scanning is a reasonable additional layer, especially for files that will be shared with other users or downloaded broadly, but it should never be the only defense — scanners miss novel payloads by design, and their absence of a detection is not proof of safety. Treat every control here — content validation, storage isolation, correct serving headers, scanning — as one layer in a stack, because any single one of them failing alone shouldn't be enough to compromise the system.