Secure PDF File Download in PHP: Serving Invoices Without Exposing Direct URLs


3 views

When building invoicing systems, many developers make the mistake of using predictable file naming patterns or exposing direct download URLs. This creates several security vulnerabilities:

  • Unauthorized access through URL guessing
  • Lack of download tracking and analytics
  • No access control or expiration mechanisms
  • Potential for hotlinking and bandwidth theft

The secure approach involves creating a proxy script that handles file delivery while keeping the actual file path hidden. Here's the basic workflow:

  1. Store files outside web root (or protect with .htaccess)
  2. Generate unique tokens for each download request
  3. Validate permissions before serving files
  4. Force download headers rather than display in browser

Here's a complete PHP solution with proper security measures:


<?php
// download.php - Secure file delivery script

// Validate request and check permissions
function authorizeDownload($fileId) {
    // Implement your authentication logic here
    // Could check session, database permissions, etc.
    return true; // Simplified for example
}

// Secure file delivery
function deliverFile($filePath) {
    if (!file_exists($filePath)) {
        http_response_code(404);
        die('File not found');
    }

    // Get file info
    $fileName = basename($filePath);
    $fileSize = filesize($filePath);
    $mimeType = mime_content_type($filePath);

    // Set headers
    header("Content-Type: $mimeType");
    header("Content-Disposition: attachment; filename=\"$fileName\"");
    header("Content-Length: $fileSize");
    header("Expires: 0");
    header("Cache-Control: must-revalidate");
    header("Pragma: public");

    // Clear output buffer
    ob_clean();
    flush();
    
    // Deliver file
    readfile($filePath);
    exit;
}

// Main execution
if (isset($_GET['id']) && authorizeDownload($_GET['id'])) {
    $fileId = $_GET['id'];
    
    // In production, you would:
    // 1. Look up the actual file path from database
    // 2. Verify user has permission to access
    // Example storage location outside web root:
    $basePath = '/var/secure/invoices/';
    $filePath = $basePath . 'invoice_' . $fileId . '.pdf';
    
    deliverFile($filePath);
} else {
    http_response_code(403);
    die('Access denied');
}
?>

For production systems, consider these additional measures:

  • Implement download tokens with expiration
  • Add rate limiting to prevent brute force attacks
  • Log all download attempts for auditing
  • Use X-Sendfile for better performance with large files
  • Consider adding watermarking for sensitive documents

Other methods to consider depending on your requirements:


// Using PHP's readfile() with output buffering
ob_start();
readfile($securePath);
$content = ob_get_clean();

// Or streaming chunks for large files
$chunkSize = 1024 * 1024;
$handle = fopen($filePath, 'rb');
while (!feof($handle)) {
    echo fread($handle, $chunkSize);
    ob_flush();
    flush();
}
fclose($handle);

When serving sensitive documents like invoices through direct file links (e.g., invoice-123.pdf), you expose several security vulnerabilities:

  • Predictable URL patterns allow brute-force access attempts
  • No access control once the URL is known
  • No logging of download activities
  • Potential hotlinking issues

Here's a complete implementation that serves files securely through PHP:

<?php
// auth_check.php - Validate user session first
session_start();
if (!isset($_SESSION['authenticated'])) {
    header('HTTP/1.0 403 Forbidden');
    exit;
}

// serve_file.php - Protected file delivery
if (isset($_GET['file_id'])) {
    $file_id = (int)$_GET['file_id'];
    $base_path = '/var/www/protected_invoices/';
    
    // Map IDs to actual filenames (could be DB query)
    $file_mapping = [
        1 => 'client_contract_2023.pdf',
        2 => 'invoice_001_apple_inc.pdf'
    ];
    
    if (isset($file_mapping[$file_id])) {
        $file_path = $base_path . $file_mapping[$file_id];
        
        if (file_exists($file_path)) {
            header('Content-Description: File Transfer');
            header('Content-Type: application/pdf');
            header('Content-Disposition: attachment; filename="'.basename($file_path).'"');
            header('Expires: 0');
            header('Cache-Control: must-revalidate');
            header('Pragma: public');
            header('Content-Length: ' . filesize($file_path));
            readfile($file_path);
            exit;
        }
    }
    
    header('HTTP/1.0 404 Not Found');
}
?>

For production environments, consider these additional protections:

// Add these headers before file delivery
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
header('X-XSS-Protection: 1; mode=block');

// Implement temporary access tokens
$expected_token = hash_hmac('sha256', $file_id, 'your_secret_key');
if (!hash_equals($expected_token, $_GET['token'] ?? '')) {
    die('Invalid access token');
}

For dynamic systems with many files, use a database solution:

<?php
$pdo = new PDO('mysql:host=localhost;dbname=invoices', 'user', 'password');
$stmt = $pdo->prepare("SELECT file_path, original_name FROM invoices WHERE id = ? AND user_id = ?");
$stmt->execute([$_GET['id'], $_SESSION['user_id']]);

if ($row = $stmt->fetch()) {
    if (file_exists($row['file_path'])) {
        // Add download record to database
        $log = $pdo->prepare("INSERT INTO download_logs (...) VALUES (...)");
        $log->execute([...]);
        
        // Serve file as previously shown
    }
}
?>

For large files or high-traffic systems:

  • Use X-Sendfile (Apache) or X-Accel-Redirect (Nginx) for better performance
  • Implement proper caching headers for public but secured files
  • Consider CDN integration with token authentication