Understanding the Security Risks in PHP File Downloads
File download functionality is a common feature in PHP applications, from serving user-uploaded documents to distributing paid content. When implemented carelessly, it becomes one of the easiest entry points for attackers. The consequences of a poorly secured download handler range from information disclosure to complete server compromise.
This guide covers the practical steps to implement secure PHP file downloads, focusing on what actually matters in production environments. The approach here applies to business websites, web applications, and any PHP project that serves files to users.
Path Traversal: The Primary Threat
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 outside the application's scope.
Consider a naive download script that accepts a filename parameter:
// Vulnerable code example
$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. The path traversal check must validate the resolved path, not the raw user input.
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.
Access Control Failures
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.
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.
File Naming for Security
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.
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
Implementing Path Validation with realpath()
PHP's realpath() function resolves relative paths to their absolute canonical form, processing all traversal sequences. It returns false if the file does not exist. This 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.
Access Control Implementation
A complete download function combines session authentication, database lookups for file metadata, and explicit authorisation checks against ownership or sharing rules. 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.
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.
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.
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.
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.
Upload Security: The Other Half of the Equation
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.
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.
Putting It All Together
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.
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.