Pacific Blue Software Logo

How to Hide Direct Download Links with PHP?

How to hide direct download links with PHP?

Introduction

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.

How It Works

The system uses a two-stage process:

  1. Landing Page (download.php) - User visits a clean URL like /download/document.pdf
  2. File Delivery (sendfile.php) - When user clicks download, the file is served and logged

The actual file location is never exposed to the user. Instead:

Prerequisites

Directory Structure

Organize 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.

Implementation: Part 1 - Landing Page (download.php)

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>

Key Features of download.php

Implementation: Part 2 - File Sender (sendfile.php)

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);
}
?>

Security Features in sendfile.php

.htaccess Configuration

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]

Understanding the Rewrite Rules

  1. First rule - Converts /download/file.pdf to download.php?name=file.pdf
  2. Second rule - Returns 403 Forbidden for any access to /private/
  3. Third rule - Prevents direct access to download.php without parameters

Enhanced Version with Logging

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);
}

Adding User Authentication

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()...

Rate Limiting Downloads

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);
}

Handling Large Files

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();
}

Common Issues and Solutions

Issue 1: Cookies Not Working

Symptoms: Users get "FileName is blank" error

Solutions:

Issue 2: Headers Already Sent

Symptoms: "Cannot modify header information - headers already sent"

Solutions:

Issue 3: File Downloads as HTML

Symptoms: Downloaded file opens as text/HTML

Solutions:

Alternative: Using Sessions Instead of Cookies

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

Best Practices

  1. Store files outside web root - Prevents direct URL access
  2. Validate everything - Never trust user input, even from cookies
  3. Use HTTPS - Protect download tokens from interception
  4. Implement logging - Track who downloads what
  5. Set appropriate timeouts - Balance security with usability
  6. Use rate limiting - Prevent bandwidth abuse
  7. Test thoroughly - Verify with different file types and sizes
  8. Monitor logs - Watch for suspicious download patterns

Performance Considerations

When to Use This Approach

Good use cases:

May be overkill for:

Related Topics


Protect Your Downloads by Hiding Direct File URLs


Back to Articles for Developers
Back to Website Articles
Protect Directories with XAMPP / Apache
How to Generate HTTP Errors with htaccess

If you found this useful, then please consider making a donation.

paypal
QR Code for donation Please donate if helpful