Accept File Uploads Without Opening a Remote Code Hole
A profile-picture field is a door to your filesystem. Validate magic bytes, re-encode, store outside the web root, and rename every file.
A file upload field looks innocent. A user picks a profile picture, you save it, you show it back. But that field is a door, and on the other side of it is your server's filesystem and, often, its ability to execute code. File upload handling is one of the most common paths to remote code execution in real applications, because the gap between "store this image" and "run this attacker's script" is smaller than it looks.
Here is the attack in one sentence: an attacker uploads a file named avatar.php containing PHP code, your app saves it under the web root, and then the attacker visits the URL of that file. The web server happily executes it as a script. Now they have a shell. Everything below exists to make each step of that chain impossible.
Never trust what the browser tells you
The browser sends a Content-Type header with every upload. It is a suggestion, not a fact, and an attacker controls it completely. They can upload a PHP web shell and label it image/png. If your validation reads that header and waves the file through, you have validated nothing.
The same goes for the filename and its extension. An attacker picks both. shell.php.png, shell.php%00.png, shell.pHp, shell.phtml are all real bypasses that defeat naive extension checks. The filename is attacker-controlled input, full stop.
Validate the actual bytes, with an allowlist
Real validation reads the file itself. The first few bytes of a file are its signature, often called magic bytes. A PNG starts with 89 50 4E 47. A JPEG starts with FF D8 FF. A PDF starts with %PDF. These are part of the binary, not metadata the attacker can simply relabel.
Use a maintained library rather than hand-rolling signature checks: file-type in Node, python-magic in Python, Apache Tika on the JVM. Then enforce an allowlist, not a blocklist. List the exact types you accept and reject everything else. A blocklist of dangerous extensions will always be incomplete, because new dangerous extensions exist that you have not thought of.
import { fileTypeFromBuffer } from 'file-type';
const ALLOWED = new Set(['image/png', 'image/jpeg', 'image/webp']);
async function validate(buffer) {
const type = await fileTypeFromBuffer(buffer);
if (!type || !ALLOWED.has(type.mime)) {
throw new Error('Unsupported file type');
}
return type;
}
Magic-byte validation alone is not enough, and the security community is clear on this. A polyglot file can be a valid image and a valid script at the same time, passing the signature check while still carrying a payload. That is why this is one layer of several, not the whole defense, the same defense-in-depth instinct that kills SQL injection with both parameterized queries and allowlists rather than relying on a single check.
Re-encode images, parse the format
The strongest single defense for image uploads is to never store the original bytes at all. Pass the upload through an image processing library (sharp in Node, Pillow in Python, ImageMagick) and re-encode it to a known-good format. The library reads the pixels and writes a fresh file. A polyglot's script payload lives in the metadata and the trailing bytes, and re-encoding throws all of that away. What you store is a clean image the library produced, not the file the attacker sent.
For PDFs and documents, parse them with a real format library and reject anything that does not parse cleanly. The goal is the same: the bytes that land on disk should be bytes your own code produced from validated content, not raw attacker input.
Store outside the web root, serve through a handler
This is the rule that turns a successful malicious upload into a dead end. Never store uploaded files anywhere the web server will execute them.
The OWASP guidance is direct: store uploads on a separate server, or if you cannot, store them outside the web root entirely. The upload directory should be a plain data directory that the web server treats as bytes to read, never as scripts to run. When a user requests a file, your application code reads it from that directory and streams it back through a controlled route. The filesystem path is never exposed and never directly reachable by URL.
Defense in depth on the same point: configure the server so it physically cannot execute scripts in the upload path even if a file slips through. On nginx, that means no PHP location block over the uploads directory. On Apache, an .htaccess that disables script handlers. If both the storage location and the server config say "no execution here," there is no path from upload to shell. For high-volume systems, pushing storage and serving to object storage on a separate origin isolates the served file from your application's cookies entirely.
Rename every file, own nothing the attacker named
Generate a fresh filename on your side, a UUID, and store that. Never use the attacker-supplied name on disk. This kills path traversal (../../etc/passwd), null-byte tricks, and overwriting of existing files. Keep the original display name as a database column if you need to show it to the user, but the bytes on disk are named by you.
While you are at it, set the storage directory and files to non-executable permissions, and strip any executable bit. A data file has no business being executable.
Bound everything
An upload field with no size limit is a denial-of-service vector and a disk-fill attack waiting to happen. Enforce a maximum size at the proxy layer (nginx client_max_body_size), at the application layer, and validate it before reading the whole file into memory. Limit the count of files per request and per user per time window. Rate-limit the endpoint like any other state-changing route, with CSRF protection that survives OAuth callbacks on the form, because an upload that mutates server state is exactly the kind of request CSRF defends.
Scan, and isolate, when the stakes are higher
If users upload files that other users will download, you are now a malware distribution vector unless you scan. Run an antivirus pass (ClamAV is the common open-source choice) on every upload before it becomes available. Record who uploaded what and when in audit logs that actually help after a breach, so a malicious file can be traced back to its source instead of leaving you guessing. For high-volume or high-risk systems, push the actual storage and serving to object storage on a separate domain, so even a served file runs in a different origin and cannot touch your application's cookies or session.
The checklist that closes the door
Put together, a hardened upload pipeline looks like this:
- Reject by size before reading the full body.
- Validate magic bytes against a strict type allowlist, ignoring the browser's
Content-Typeand the filename. - Re-encode images and parse documents so stored bytes are ones your code produced.
- Generate a UUID filename, discard the attacker's name.
- Store outside the web root, in a non-executable directory.
- Configure the server so the upload path cannot execute scripts, as a second line.
- Serve files through an application route, never a direct filesystem URL.
- CSRF-protect and rate-limit the endpoint.
- Scan if files are shared between users.
Every one of these is cheap. Skipping any one of them is how a profile-picture field becomes a breach. We build this pipeline into every web app that accepts uploads, and it is exactly the kind of gap our security audits look for first, because it is the kind that ends with someone else holding a shell on your box.
A file upload is not a feature you bolt on at the end. It is a privileged trust boundary, and it deserves the same care as your authentication, the same standard behind the security headers every Next.js app should ship and scoping API tokens so a leak cannot touch everything. Treat every uploaded byte as hostile until your own code has read it, validated it, and rewritten it. Then the door opens only the way you intended.






