Understanding the Security Risks in PHP File Downloads
File download functionality appears in most PHP applications, from serving user-uploaded documents to distributing premium content behind a paywall. This feature is deceptively simple to implement but carries significant security implications that are easy to overlook.
When a download handler is implemented carelessly, it becomes one of the most reliable entry points for attackers. The consequences range from leaking sensitive server configuration files to gaining complete control over the application. This guide walks through the practical steps to implement secure PHP file downloads that hold up in production environments.
The principles here apply to business websites, web applications, and any PHP project that serves files to users. If you are working with PHP applications in a business context, reviewing these patterns against your current implementation is worthwhile.
Path Traversal: The Primary Threat to File Downloads
Path traversal is the most common vulnerability affecting file download functionality. It occurs when an application uses user input to construct file paths without proper validation. An attacker can manipulate the input to escape the intended directory and access files completely outside the application's scope.
Consider a naive download script that accepts a filename parameter directly from the URL:
// Vulnerable code example - do not use in production
$filename = $_GET['file'];
readfile('/var/www/uploads/' . $filename);
With this pattern, an attacker requesting ?file=../../../../etc/passwd receives the system password file. The ../ sequence traverses up directory levels on Unix systems, while ..\ performs the same function on Windows servers.
The vulnerability persists even when a developer attempts to sanitise input by filtering ../ characters, because filesystem symlinks can cause the resolved path to differ from what the concatenation suggests. A file named malicious.pdf might actually be a symlink pointing to /etc/passwd. The path traversal check must validate the resolved path, not the raw user input.
PHP provides tools to handle this correctly. The realpath() function resolves relative paths to their absolute canonical form, processing all traversal sequences. It returns false if the file does not exist, which makes it reliable for validating that the resolved path remains within an allowed directory.
function secureFilePath(string $userFilename, string $baseDirectory): string|false {
$requestedPath = $baseDirectory . DIRECTORY_SEPARATOR . $userFilename;
$realPath = realpath($requestedPath);
if ($realPath === false) {
return false;
}
$realBase = realpath($baseDirectory);
if ($realBase === false) {
return false;
}
if (strpos($realPath, $realBase . DIRECTORY_SEPARATOR) !== 0) {
return false;
}
return $realPath;
}
This function defeats path traversal attacks because it evaluates the path after all ../ sequences have been resolved. An attacker providing ../../etc/passwd will have the request resolved to /etc/passwd, which will not start with the allowed base directory path, causing the function to return false.
Information Disclosure Through Error Messages
Verbose error messages reveal internal system details that attackers use to refine their approaches. In the context of file downloads, this commonly manifests as PHP error messages exposing full file paths, database table names, or server directory structures.
Production servers should display generic error pages rather than detailed PHP errors. This configuration is controlled through display_errors in php.ini:
display_errors = Off
log_errors = On
error_log = /var/log/php_errors.log
Even with error display disabled, your application code should catch file operation failures and return minimal information to the user while logging the full details server-side. A download handler should return a generic message like "File not found" or "Access denied" without revealing whether the file exists on the filesystem or what the internal path structure looks like.
Authentication and Authorisation: Two Distinct Checks
Authentication and authorisation are distinct security concerns that both require attention in download handlers. Authentication confirms the user's identity, typically through session verification. Authorisation confirms that the authenticated user has permission to access the specific requested file.
A common mistake is checking only whether a user is logged in without verifying their right to download the particular file. A user with an active session may still lack permission to access certain files, such as another user's uploaded documents or premium content their account has not purchased.
Every download request must trigger both an authentication check and an authorisation check before serving any content. The following pattern demonstrates this approach:
function downloadFile(int $fileId, int $userId, mysqli $db): void {
if (!isset($_SESSION['user_id']) || $_SESSION['user_id'] !== $userId) {
http_response_code(403);
exit('Access denied');
}
$stmt = $db->prepare(
"SELECT filepath, original_name, mime_type, owner_id FROM files WHERE id = ?"
);
$stmt->bind_param('i', $fileId);
$stmt->execute();
$result = $stmt->get_result();
$file = $result->fetch_assoc();
$stmt->close();
if (!$file) {
http_response_code(404);
exit('File not found');
}
$stmt = $db->prepare("
SELECT 1 FROM file_shares
WHERE file_id = ? AND user_id = ?
UNION
SELECT 1 FROM files WHERE id = ? AND owner_id = ?
LIMIT 1
");
$stmt->bind_param('iiii', $fileId, $userId, $fileId, $userId);
$stmt->execute();
$result = $stmt->get_result();
if ($result->num_rows === 0) {
http_response_code(403);
exit('Access denied');
}
$stmt->close();
serveFile($file['filepath'], $file['original_name'], $file['mime_type']);
}
Using prepared statements prevents SQL injection attacks when handling file IDs. The authorisation query checks both explicit file sharing rules and ownership before serving the file.
Secure File Storage Architecture
The foundation of secure file downloads is storage placement. Files accessible via direct URLs are fundamentally insecure because URLs leak through server logs, browser history, browser tabs, and HTTP referrer headers. Even authenticated URL protection can fail when a user shares the link or when a referrer header exposes the URL to a third party.
The correct architecture stores files outside the web root directory, where the web server cannot serve them directly. The web root is the directory configured as DocumentRoot in Apache or the equivalent in other servers. Files outside this directory cannot be accessed via HTTP requests regardless of their filesystem permissions.
For example, if your Apache DocumentRoot is set to /var/www/html, files in /var/www/html/documents/ are accessible at example.com/documents/. Files stored at /var/www/storage/ are completely inaccessible via HTTP and can only be reached through PHP code that explicitly reads and outputs the file content.
Random File Naming to Obscure Content
Storing files with their original names creates unnecessary risk. A file served at download.php?file=financial-report-q4.pdf reveals sensitive information about the file's existence and content to anyone who sees that URL. Attackers can enumerate files by guessing common naming patterns like invoice-2024-01.pdf or contract-draft.pdf.
A secure approach uses random identifiers for file storage. The original filename, MIME type, and access control rules are stored in a database, while the filesystem contains only the randomly named file:
// Database stores: id, original_name, storage_name, mime_type, owner_id, access_level
// Filesystem stores: a3f8b2c1-4d5e-6f7g.pdf (random UUID-based name)
// Download URL becomes: download.php?id=a3f8b2c1-4d5e-6f7g
When the URL contains only a random identifier rather than a filename, there is nothing meaningful to guess or enumerate. This approach also prevents information disclosure through browser history and server logs.
Sending Secure Response Headers
The serveFile function delivers the file to the browser. Two headers are critical: Content-Type tells the browser what type of content is being sent, and Content-Disposition controls whether the browser displays the content or triggers a download.
For security-sensitive downloads, Content-Disposition: attachment prevents the browser from executing potentially dangerous content. An HTML file served with attachment disposition will not execute as JavaScript, protecting against stored XSS attacks through uploaded files.
The X-Content-Type-Options: nosniff header prevents MIME type sniffing, ensuring the browser respects the declared content type. The X-Download-Options: noopen header prevents files from opening directly in Internet Explorer. These headers add important protective layers to your download handling.
function serveFile(string $filepath, string $originalName, string $mimeType): void {
if (!file_exists($filepath) || !is_readable($filepath)) {
http_response_code(404);
exit('File not found');
}
header('X-Content-Type-Options: nosniff');
header('X-Download-Options: noopen');
$safeMimeTypes = ['application/pdf', 'image/jpeg', 'image/png',
'application/vnd.ms-excel', 'application/zip'];
header('Content-Type: ' . (in_array($mimeType, $safeMimeTypes, true)
? $mimeType
: 'application/octet-stream'));
$safeName = preg_replace('/[^\w\.\-]/', '_', $originalName);
header('Content-Disposition: attachment; filename="' . $safeName . '"');
header('Content-Transfer-Encoding: binary');
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
header('Expires: 0');
readfile($filepath);
exit;
}
The regex /[^\w\.\-]/ strips any character that is not a word character, period, or hyphen from the filename, preventing header injection attacks through malicious filenames. The cache control headers ensure sensitive files are not stored in browser caches where other users of the same device might access them.
Handling Large Files Efficiently
Loading a large file entirely into memory with readfile() can exhaust server resources on memory-limited hosting. A chunked approach reads and outputs the file in sections, keeping memory usage constant regardless of file size.
Chunked delivery also enables resumable downloads, which significantly improve the user experience for large files on unreliable connections. Users can resume an interrupted download rather than starting over, which matters particularly for files over 50MB.
function serveLargeFile(string $filepath, string $originalName): void {
if (!file_exists($filepath) || !is_readable($filepath)) {
http_response_code(404);
exit('File not found');
}
$fileSize = filesize($filepath);
header('X-Content-Type-Options: nosniff');
header('X-Download-Options: noopen');
header('Content-Type: application/octet-stream');
$safeName = preg_replace('/[^\w\.\-]/', '_', $originalName);
header('Content-Disposition: attachment; filename="' . $safeName . '"');
header('Content-Length: ' . $fileSize);
header('Cache-Control: no-store, no-cache');
header('Pragma: no-cache');
$handle = fopen($filepath, 'rb');
if ($handle === false) {
http_response_code(500);
exit('Unable to open file');
}
if (isset($_SERVER['HTTP_RANGE'])) {
preg_match('/bytes=(\d+)-(\d*)/', $_SERVER['HTTP_RANGE'], $matches);
$start = intval($matches[1]);
$end = $matches[2] !== '' ? intval($matches[2]) : $fileSize - 1;
$length = $end - $start + 1;
header('HTTP/1.1 206 Partial Content');
header('Content-Length: ' . $length);
header('Content-Range: bytes ' . $start . '-' . $end . '/' . $fileSize);
fseek($handle, $start);
$remaining = $length;
while ($remaining > 0 && !feof($handle)) {
$chunkSize = min(8192, $remaining);
echo fread($handle, $chunkSize);
$remaining -= $chunkSize;
flush();
}
} else {
$remaining = $fileSize;
while ($remaining > 0 && !feof($handle)) {
$chunkSize = min(8192, $remaining);
echo fread($handle, $chunkSize);
$remaining -= $chunkSize;
flush();
}
}
fclose($handle);
exit;
}
Before modifying server configurations or implementing large file handling, ensure you have current backups of any files and configurations that might be affected by changes. Testing on a staging environment first helps identify configuration issues before they affect production.
Upload Security: The Foundation of Safe Downloads
Secure downloads depend on secure uploads. An attacker who can upload malicious files defeats the purpose of secure download handling. Uploaded files must be validated before storage using server-side checks, not just browser-reported MIME types.
The MIME type that browsers report can be spoofed and should never be trusted alone. PHP's fileinfo extension reads the actual file content to determine its true type:
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$actualMime = finfo_file($finfo, $tempFilePath);
finfo_close($finfo);
$allowedTypes = ['image/jpeg', 'image/png', 'application/pdf', 'application/zip'];
if (!in_array($actualMime, $allowedTypes, true)) {
unlink($tempFilePath);
exit('File type not permitted');
}
Beyond MIME type validation, consider virus scanning uploaded files using ClamAV or similar tools before storing them permanently. Reject or quarantine executable files, scripts, and documents containing macros, as these pose the highest risk if served to other users.
Regardless of validation, never place uploaded files in directories that allow PHP execution. A .htaccess file in your upload directory preventing script execution adds defence in depth:
# Deny script execution in uploads directory
<FilesMatch "\.php$">
Order Deny,Allow
Deny from all
</FilesMatch>
Why Upload Security Matters for Download Handlers
Even the most secure download handler cannot protect against malicious files that were uploaded successfully. If an attacker uploads a PHP shell script and the upload directory allows script execution, the attacker gains full server access through the web server. The download handler then becomes irrelevant because the attacker is already operating within the application.
A complete secure file handling strategy combines several controls: random storage names that obscure file identities, session-based access control verified on every request, path traversal prevention using realpath(), and careful response header configuration. Broader PHP application security follows similar principles and is covered in the securing PHP applications checklist.
Preventing Direct File Access Altogether
Many PHP applications make the mistake of storing user uploads inside the web root. Even if the files are not directly linked, anyone who discovers or guesses the URL can access them. Moving uploads outside the web root is the single most effective step you can take toward secure file handling.
When files are stored outside the web root, the only way to access them is through your PHP code. This means every file access goes through your authentication, authorisation, and validation logic. The web server cannot be tricked into serving files directly because it simply does not have access to those directories via HTTP.
If moving files outside the web root is not possible in your hosting environment, consider using a cloud storage service like Amazon S3 with signed URLs or Cloudflare Workers. These services handle access control at the infrastructure level and remove the file serving burden from your PHP application entirely.
Logging and Monitoring Download Activity
Production file download handlers should log access attempts. Log the file ID or name, the user ID making the request, the timestamp, whether the download was successful, and any authorisation failures. This audit trail helps identify suspicious activity and investigate incidents.
Patterns worth monitoring include users downloading unusual volumes of files, access attempts to files they have not previously accessed, and failed authorisation attempts that might indicate someone probing for accessible content. Regular review of these logs helps catch issues before they escalate.
When to Review Your File Download Implementation
File download handlers should be reviewed when adding new file types, changing storage locations, integrating new authentication systems, or after any significant application update. The security of your file handling depends on the interaction between multiple components, so changes in one area can affect the security of another.
If your application handles sensitive documents, paid content, or user-uploaded files, reviewing the current implementation against these principles is worthwhile. The patterns described here apply to most PHP applications, though specific implementations vary depending on your framework, hosting environment, and business requirements. A practical PHP security checklist for business websites covers additional measures worth considering.
Summary of Secure Download Practices
Secure PHP file downloads require attention across multiple layers: storage architecture, input validation, access control, and response handling. No single measure is sufficient, but implementing all of them together creates a substantially more secure file serving system.
The key principles are straightforward. Store files outside the web root and serve them only through PHP code. Validate file paths using resolved paths, not raw input. Check both authentication and authorisation for every request. Use appropriate headers to prevent MIME sniffing and unwanted execution. Scan uploads before storage and prevent script execution in upload directories. Log download activity to support incident investigation.
If you want a practical review of your current setup, you can get in touch with details of your application, the file types you serve, and any specific security concerns you want to address.