Any site with a contact form that takes attachments, a profile picture, or a document submission has a directory that strangers write files into. How that directory is configured decides whether that is a feature or a way into the server.
Nothing in it may be executed
This is the whole subject in one sentence.
If the server will run a file placed in the upload directory, then anyone who can upload a file can run code on your server. That is the most reliably exploited weakness in sites that accept uploads, and it does not require any clever technique, only a directory that executes.
There are two ways to prevent it, and the first is better.
Best: keep uploads outside the web root
Store them above public_html, where the web server will not serve them at all, and deliver them through your own code:
/home/username/uploads/ ← files land here /home/username/public_html/ ← the website
Your application reads the file and sends it, which means you decide who may see it and what content type it is served as. Nothing in that directory is reachable by URL, whatever it is named.
This also solves access control, which the alternative does not, files in a public directory are public to anyone who knows the address, and addresses leak.
Otherwise: disable execution explicitly
When the application insists on a public directory, refuse execution there:
<FilesMatch "\.(php|phtml|php[0-9]|pl|py|cgi|sh)$"> Require all denied </FilesMatch> Options -ExecCGI php_flag engine off
Place that in an .htaccess inside the upload directory. Then test it, put a harmless PHP file there yourself, request it, and confirm you get a refusal rather than output.
An untested rule is a belief. .htaccess rules worth knowing goes over the file, and note the rule has to survive the application rewriting the directory, which some do.
The name and the type are chosen by the uploader
Neither the file extension nor the type the browser reported is evidence of anything. Both come from the person uploading.
Determine the real type by inspecting the file, and compare it against a list of what you accept. An allow list, not a block list. Block lists are always missing something.
Then rename the file to something you generated. This removes several problems at once: names designed to escape the directory, names with characters that confuse other tools, and collisions between two users uploading the same filename.
Keep the original name in the database as a label if you need to show it.
Bound the size and the count
An upload path with no limit is a way to fill the hosting account.
That matters more than it sounds, because a full account cannot write: mail is refused, database writes fail, sessions break, and the errors mention none of it. Monitoring your hosting resources deals with recognising it.
Limit the file size in the application as well as in PHP, and limit how many uploads one visitor can make in a period. The PHP settings are a backstop, not the policy. There is more on the two values that must move together in configuring PHP settings in cPanel.
Images deserve one extra step
An image can carry data that is not an image, including metadata and, in some formats, embedded content.
Re-encoding the image on receipt, reading it and writing a new file: discards everything that was not picture data. It also strips the metadata, which is worth doing anyway: uploaded photographs routinely carry device models, timestamps and locations that you would then be republishing.
For video the equivalent step is conversion, and it needs its own precautions. See ffmpeg-hosting/how-to-process-untrusted-video-uploads-safely.html">processing untrusted video uploads safely.
Permissions
Directories 755, files 644. The web server needs to write, and that comes from ownership rather than from loosening permissions.
Never 777. It appears in forum advice as a fix for upload problems and it makes the file writable by anyone on the server. Understanding file permissions and ownership walks through what a permissions error usually really is.
Check what is in there
find ~/public_html/uploads -type f -name "*.ph*" -o -name "*.cgi" | head
On an existing site, run that before assuming anything. Finding a PHP file in an upload directory is not ambiguous, and it means the investigation starts now. Cleaning up a hacked site goes into what follows.
Serve uploads through your own code
Keeping files outside the web root only helps if the delivery path is deliberate. The shape worth using:
$file = '/home/username/uploads/' . basename($record['stored_name']);
if (!is_readable($file)) { http_response_code(404); exit; }
header('Content-Type: ' . $record['verified_type']);
header('Content-Disposition: attachment; filename="' . $record['display_name'] . '"');
readfile($file);
Three details matter. The path comes from your database rather than from the request, so no input can traverse out of the directory. The content type is the one you verified at upload, not one derived from the name. And the disposition header tells the browser to download rather than render, which prevents a file being interpreted as HTML in the visitor's browser.
That last point catches the case people miss: an uploaded file served inline can execute script in the context of your domain, even though nothing executed on the server.
Filenames need normalising, not just replacing
Generating your own name solves most of it. Where the original must be preserved for display, it still needs handling.
Names arrive containing path separators, null bytes, right-to-left override characters that make an executable look like an image, and characters that behave differently once written to disk.
Store the original as a label in the database, escaped on output like any other user content, and never use it to construct a path. The two uses are separate and conflating them is where the vulnerability lives.
Quotas per user, not just per file
A size limit stops one enormous file. It does nothing about a thousand acceptable ones.
Any upload path open to registered users needs a cap on total storage and on uploads per period. Without it, one account can fill the hosting account, which stops mail, database writes and sessions at the same moment.
Where uploads are anonymous, that limit has to be by address and time, and it should be low. An anonymous upload form with no rate limit is a file host somebody else will discover before you do. Stopping bots and scrapers explains the mechanism.
Decide how long files are kept
Upload directories grow permanently unless something removes from them, and most applications never do.
Attachments from a contact form six years ago are still there, still containing whatever people sent, and still your responsibility if the account is compromised.
find ~/uploads -type f -mtime +365 | wc -l du -sh ~/uploads
Set a retention period appropriate to what the files are for, and apply it. Data you no longer need is only a liability. What a website needs for privacy compliance walks through the obligations that make it one.