In short
Treat every upload as untrusted. Validate the actual file content rather than its extension or declared type, store files outside the web root or in object storage, generate your own filenames, cap size before reading the request, and serve files from a path that cannot execute them — ideally a separate domain. The dangerous mistake is trusting anything the client tells you about the file.
The threat, stated plainly
An upload endpoint lets an anonymous person place a file on infrastructure you control, and often lets other people retrieve it. Every safeguard below follows from that.
The specific risks: a file that executes on your server, a file that executes in another user's browser, a file that fills your disk, a file that crashes whatever processes it, and a file whose name escapes the directory you intended.
Never trust what the client says
Three things arrive from the client and none can be believed.
The filename. Attacker-controlled. It may contain path traversal sequences, null bytes, or a double extension like invoice.pdf.php designed to defeat naive checks.
The extension. Trivially changed. A PHP script renamed .jpg is still a PHP script.
The declared content type. The Content-Type header is set by the client and means nothing.
So validation has to inspect the file itself.
Validate the content
Check the magic bytes. Real file types begin with recognisable signatures. Read the first bytes and confirm they match what you expect, using a library that inspects content rather than trusting metadata.
Use an allowlist, never a blocklist. List the types you accept. A blocklist of dangerous extensions will always be missing one.
Verify it actually parses. For images, decode it — a file with correct magic bytes can still be malformed in ways that crash an image library. Decoding in a sandboxed process is safer still, since image parsers are a historically rich source of vulnerabilities.
Re-encode images where you can. Decoding and re-writing an image strips embedded payloads, metadata, and anything hidden after the image data. It also normalises the output. For user avatars and similar, this is the strongest single measure available.
Cap the size before reading the body, at the web server or framework level. A limit enforced after loading the file into memory has already lost.
Generate your own filenames
Never store a file under the name it arrived with.
Generate a fresh identifier, apply the extension your validation determined, and keep the original name in the database only as a label to show the user. That removes path traversal, collisions, case-sensitivity surprises, and every filename-based trick at once.
If you must preserve something recognisable, sanitise it to a known-safe character set and use it as a display label, never as the path.
Store files outside the web root
The classic vulnerability is uploading a script into a directory the web server will execute.
Object storage is the right default — a bucket, with the application holding credentials. Files are not on your application server, cannot be executed there, and scale without disk planning.
If you must store locally, put them outside the document root and serve them through a handler that checks authorisation and streams the file. A directory the web server serves directly is a directory that may execute what is in it.
Either way, ensure the storage path cannot execute anything, and that directory listing is off.
Serving files without creating an XSS hole
This half is often overlooked.
An uploaded HTML or SVG file, served from your domain and opened in a browser, runs script in your origin — with access to your cookies and session. SVG is the common surprise, because it looks like an image and is actually a document that can contain script.
Defences, in order of strength:
Serve user files from a separate domain. Different origin, so a malicious file cannot touch your session. This is what large platforms do, and it is the only complete answer.
Set Content-Disposition: attachment so files download rather than render.
Set X-Content-Type-Options: nosniff so browsers do not guess a type from content and render something as HTML.
Serve a correct, restrictive Content-Type determined by your validation, not by the upload.
Sanitise SVGs if you must render them inline, with a library built for it.
Access control
A file with an unguessable URL is not access-controlled; it is obscured. If files are private, check authorisation on every request.
For object storage, signed URLs with short expiry are the standard approach — your application checks permission, then issues a time-limited link. Avoid making buckets public and relying on unpredictable keys, which leak through referrers, logs, and shared links.
Denial of service
Limit size and count, per request and per account.
Rate limit uploads. Otherwise an endpoint that accepts a 10MB file is an endpoint that accepts a thousand of them — see rate limiting.
Process asynchronously. Thumbnailing, scanning and transcoding belong in a background job, not in the request.
Guard against decompression bombs if you accept archives or formats that expand — a small file expanding to gigabytes is an easy outage.
Watch storage growth and have a deletion policy, so orphaned files from abandoned forms do not accumulate forever.
A working checklist
Size capped at the server. Type allowlisted and verified by content. Images re-encoded. Filename generated by you. Stored in object storage or outside the web root. Served from a separate domain, or at minimum as an attachment with nosniff. Authorisation checked per request. Uploads rate limited. Heavy processing in a background job.
That covers the realistic attack surface, and most of it is one-time configuration.
If you are adding uploads to something that handles client data, book a call.
Common questions
How do you validate an uploaded file safely?
Inspect the file's actual content rather than its name, extension, or declared Content-Type, all of which the client controls. Check magic bytes against an allowlist of accepted types, confirm the file genuinely parses, and re-encode images where possible — decoding and rewriting strips embedded payloads and metadata.
Where should uploaded files be stored?
In object storage as a default, so files never sit on your application server and cannot be executed there. If storing locally, keep them outside the document root and serve through a handler that checks authorisation, because a directory the web server serves directly may execute what is in it.
Why are SVG uploads dangerous?
An SVG looks like an image but is a document that can contain script. Served from your domain and opened in a browser, it runs in your origin with access to cookies and session. Serve user files from a separate domain, force download with Content-Disposition, set nosniff, and sanitise SVGs if you must render them inline.
Should I keep the original filename?
Only as a display label in the database, never as the storage path. Generate your own identifier and apply the extension your validation determined — that removes path traversal, collisions, case-sensitivity surprises, and double-extension tricks like invoice.pdf.php in one step.
Is an unguessable file URL enough security?
No — that is obscurity, not access control. Unguessable URLs leak through referrer headers, server logs, and people sharing links. For private files, check authorisation on every request, or use signed URLs with short expiry issued after your application has verified permission.
