When you host downloadable files on your website, you face several security and bandwidth concerns:
The solution is to hide the actual file path and serve files through a PHP script that validates access, tracks downloads, and controls delivery. This article shows you how to implement a secure download system using PHP, cookies, and .htaccess URL rewriting.
The system uses a two-stage process:
/download/document.pdfThe actual file location is never exposed to the user. Instead:
http://yoursite.com/download/document.pdf/var/www/private/files/document.pdf (not web accessible)http://yoursite.com/sendfileOrganize your files like this:
your-website/
├── public_html/ (Web-accessible root)
│ ├── .htaccess
│ ├── download.php
│ ├── sendfile.php
│ └── download/ (Virtual directory - doesn't exist)
│
└── private/ (NOT web-accessible)
└── files/ (Your actual files stored here)
├── document.pdf
├── software.zip
└── manual.doc
Important: The private/files/ directory should be outside your
web root so files cannot be accessed directly via URL.
This is the page users visit to see file information and access the download button:
<?php
// Returns the param by name, if not found then get it from the current_url.
// So this works regardless of how the .htaccess redirection has been done.
function Get_Param ($name, $current_url)
{
// Try to get from $_GET first
$par = filter_var ($_GET [$name] ?? '', FILTER_SANITIZE_STRING);
if ($par) {
return $par;
}
// If not in $_GET, extract from URL path
$pi = pathinfo ($current_url);
$pi = $pi['filename'] ?? '';
// Check if this is a simple filename (no = sign)
if (strpos ($pi, '=') === false) {
return $pi;
} else {
return '';
}
}
// Save information in cookie with an expiry of 1 hour
function Save_Info ($file)
{
$expiry = time() + 3610; // 1 hour + 10 seconds buffer
$path = '/'; // Available to entire site
// Use httponly and secure flags for better security
setcookie ('Download', $file, [
'expires' => $expiry,
'path' => $path,
'httponly' => true, // Prevent JavaScript access
'secure' => true, // Only send over HTTPS
'samesite' => 'Strict' // CSRF protection
]);
}
// Get current URL for parsing
$current_url = $_SERVER['REQUEST_URI'];
// Get the filename from the param or URL
$file = Get_Param ('name', $current_url);
// Validate filename exists and is safe
if (empty($file)) {
die('Error: No file specified');
}
// Save to cookie for later retrieval
Save_Info ($file);
// Display the download page
?>
<!DOCTYPE html>
<html>
<head>
<title>Download: <?php echo htmlspecialchars($file); ?></title>
</head>
<body>
<h1>Download: <?php echo htmlspecialchars($file); ?></h1>
<p>Click the button below to download this file.</p>
<p>This link expires in 1 hour.</p>
<!-- Download button links to sendfile.php -->
<a href="/sendfile" class="download-button">Download Now</a>
<p><small>File size: [Your code to show file size]</small></p>
<p><small>Last modified: [Your code to show date]</small></p>
</body>
</html>
This script validates the request and serves the actual file:
<?php
// Sends the file and terminates script
function Send_File ($file)
{
// Set headers to force download
header ('Content-Description: File Transfer');
header ('Content-Type: application/octet-stream');
header ('Content-Disposition: attachment; filename="'.basename($file).'"');
header ('Expires: 0');
header ('Cache-Control: must-revalidate');
header ('Pragma: public');
header ('Content-Length: ' . filesize($file));
// Clear output buffer and send file
flush ();
if (readfile ($file)) {
// Optional: Log the download
// LogIt ($file);
}
die (); // Stop execution after sending file
}
// Load info from cookie and remove it (one-time use)
function Load_Info ()
{
$file = $_COOKIE ['Download'] ?? '';
// Unset the cookie immediately (single use)
setcookie ('Download', '', time() - 3600, '/');
return $file;
}
// Validate filename for security
function Is_Valid ($file, $path)
{
// Check if filename is empty
if (!$file) {
return 'FileName is blank';
}
// Decode URL encoding
$file = urldecode($file);
// Validate filename format (letters, numbers, dash, underscore, dot)
// Must start with letter/number, end with letter
if (!preg_match('/^[^.][-a-z0-9_.]+[a-z]$/i', $file)) {
return 'Invalid filename format';
}
// Check for directory traversal attempts
if (strpos($file, '..') !== false || strpos($file, '/') !== false) {
return 'Invalid filename - security violation';
}
// Check if file exists
if (!file_exists($path.$file)) {
return 'File does not exist';
}
// All checks passed
return false;
}
// Your actual path to the files (outside web root)
$path = '../private/files/';
// Load info from cookie
$file = Load_Info ();
// Validate the filename and path
$error = Is_Valid ($file, $path);
if ($error) {
// Show error and stop
http_response_code(403);
die('Error: ' . htmlspecialchars($error));
} else {
// Send the file
Send_File ($path . $file);
}
?>
Use .htaccess to create clean URLs and protect your files directory:
# Enable URL rewriting
RewriteEngine On
# Redirect /download/filename to download.php?name=filename
RewriteRule ^download/(.+)$ download.php?name=$1 [L,QSA]
# Prevent direct access to private directory
RewriteRule ^private/ - [F,L]
# Optional: Prevent direct access to .php files
# (Force users to go through clean URLs)
RewriteCond %{REQUEST_URI} ^/download\.php$
RewriteCond %{QUERY_STRING} !name=
RewriteRule .* - [F,L]
/download/file.pdf to download.php?name=file.pdf/private/Add download tracking by implementing a logging function:
function LogIt ($file)
{
$log_file = '../private/logs/downloads.log';
$timestamp = date('Y-m-d H:i:s');
$ip = $_SERVER['REMOTE_ADDR'];
$user_agent = $_SERVER['HTTP_USER_AGENT'];
$log_entry = sprintf(
"[%s] IP: %s | File: %s | User-Agent: %s\n",
$timestamp,
$ip,
$file,
$user_agent
);
file_put_contents($log_file, $log_entry, FILE_APPEND);
}
// Call in Send_File function:
if (readfile ($file)) {
LogIt ($file);
}
Require users to be logged in before downloading:
// In download.php, before Save_Info():
session_start();
// Check if user is logged in
if (!isset($_SESSION['user_id'])) {
header('Location: /login.php?redirect=/download/' . urlencode($file));
exit;
}
// Optional: Check user permissions
if (!user_has_permission($_SESSION['user_id'], $file)) {
die('Error: You do not have permission to download this file');
}
// Continue with Save_Info()...
Prevent abuse by limiting downloads per IP address:
function Check_Rate_Limit()
{
$ip = $_SERVER['REMOTE_ADDR'];
$rate_file = '../private/temp/rate_' . md5($ip) . '.txt';
// Check last download time
if (file_exists($rate_file)) {
$last_download = (int)file_get_contents($rate_file);
$time_diff = time() - $last_download;
// Require 10 seconds between downloads
if ($time_diff < 10) {
return 'Please wait ' . (10 - $time_diff) . ' seconds before downloading again';
}
}
// Update rate limit file
file_put_contents($rate_file, time());
return false; // No error
}
// In sendfile.php, before sending file:
$rate_error = Check_Rate_Limit();
if ($rate_error) {
die('Error: ' . $rate_error);
}
For files larger than your PHP memory limit, use chunked reading:
function Send_Large_File($file)
{
// Set headers (same as before)
header('Content-Description: File Transfer');
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="'.basename($file).'"');
header('Content-Length: ' . filesize($file));
// Open file for reading
$fp = fopen($file, 'rb');
// Read and output in chunks
while (!feof($fp)) {
echo fread($fp, 8192); // 8KB chunks
flush();
}
fclose($fp);
die();
}
Symptoms: Users get "FileName is blank" error
Solutions:
Symptoms: "Cannot modify header information - headers already sent"
Solutions:
Symptoms: Downloaded file opens as text/HTML
Solutions:
For better reliability, use PHP sessions:
// In download.php:
session_start();
$_SESSION['download_file'] = $file;
$_SESSION['download_expires'] = time() + 3600;
// In sendfile.php:
session_start();
if (!isset($_SESSION['download_file'])) {
die('Error: No download session found');
}
if (time() > $_SESSION['download_expires']) {
unset($_SESSION['download_file']);
die('Error: Download link has expired');
}
$file = $_SESSION['download_file'];
unset($_SESSION['download_file']); // One-time use
Good use cases:
May be overkill for:
Please donate if helpful