Secure File Download Handler in WordPress

Serving protected files — invoices, premium downloads, user exports — requires streaming file content through PHP rather than linking directly to a URL. A direct link bypasses all WordPress access control; a PHP download handler checks capabilities first and then streams the file with the correct headers.

Problem: WordPress's wp_handle_upload() returns a public URL for every uploaded file — there is no built-in mechanism to restrict access to files based on user role or capability, leaving sensitive documents accessible to anyone with the URL.

Solution: Store protected files outside the web root (above public_html/), serve them via a PHP controller that checks current_user_can() before calling readfile(), and set headers with header('Content-Disposition: attachment'). Register a custom URL endpoint with add_rewrite_rule() that maps to the controller.

The examples below show a download handler registered as a custom rewrite endpoint, capability checking, path traversal prevention, and chunked streaming for large files.

<?php
// register-download-endpoint.php
// Store protected files OUTSIDE the webroot: /var/www/private/downloads/
// Never in /wp-content/uploads/ — those are publicly accessible.

define( 'PROTECTED_FILES_DIR', '/var/www/private/downloads/' );

// Register a rewrite endpoint: /download/{token}/
add_action( 'init', function() {
    add_rewrite_endpoint( 'download', EP_ROOT );
} );

add_action( 'template_redirect', function() {
    $token = get_query_var( 'download' );
    if ( ! $token ) return;

    myplugin_serve_protected_file( sanitize_key( $token ) );
    exit;
} );

function myplugin_serve_protected_file( string $token ): void {
    // 1. Resolve token → file mapping (stored in postmeta or options)
    $file_info = myplugin_get_file_by_token( $token );
    if ( ! $file_info ) {
        wp_die( 'File not found.', 'Not Found', [ 'response' => 404 ] );
    }

    // 2. Check capability — e.g., must be order owner or admin
    if ( ! myplugin_user_can_download( $file_info ) ) {
        wp_die( 'Access denied.', 'Forbidden', [ 'response' => 403 ] );
    }

    // 3. Resolve absolute path — prevent path traversal
    $filename  = $file_info['filename'];
    $real_base = realpath( PROTECTED_FILES_DIR );
    $real_file = realpath( PROTECTED_FILES_DIR . $filename );

    if ( ! $real_file || ! str_starts_with( $real_file, $real_base . DIRECTORY_SEPARATOR ) ) {
        wp_die( 'Invalid file path.', 'Forbidden', [ 'response' => 403 ] );
    }

    if ( ! is_readable( $real_file ) ) {
        wp_die( 'File unavailable.', 'Not Found', [ 'response' => 404 ] );
    }

    // 4. Send headers
    $mime = mime_content_type( $real_file ) ?: 'application/octet-stream';
    header( 'Content-Type: '        . $mime );
    header( 'Content-Disposition: attachment; filename="' . basename( $filename ) . '"' );
    header( 'Content-Length: '      . filesize( $real_file ) );
    header( 'Cache-Control: private, no-cache, no-store, must-revalidate' );
    header( 'X-Content-Type-Options: nosniff' );

    // 5. Stream in chunks (prevents memory exhaustion for large files)
    $fh = fopen( $real_file, 'rb' );
    while ( ! feof( $fh ) ) {
        echo fread( $fh, 1_048_576 ); // 1 MB chunks
        flush();
    }
    fclose( $fh );
}

Token generation and capability check:

<?php
// Generate a time-limited signed download token (store in transient)
function myplugin_create_download_token( int $order_id, string $filename, int $user_id ): string {
    $token = bin2hex( random_bytes( 16 ) );  // cryptographically secure random token
    set_transient( 'dl_' . $token, [
        'filename' => $filename,
        'order_id' => $order_id,
        'user_id'  => $user_id,
        'expires'  => time() + HOUR_IN_SECONDS * 24,
    ], DAY_IN_SECONDS );
    return $token;
}

function myplugin_get_file_by_token( string $token ): array|false {
    $data = get_transient( 'dl_' . $token );
    if ( ! $data || time() > $data['expires'] ) {
        delete_transient( 'dl_' . $token );
        return false;
    }
    return $data;
}

function myplugin_user_can_download( array $file_info ): bool {
    if ( current_user_can( 'manage_woocommerce' ) ) return true;
    return is_user_logged_in() && get_current_user_id() === (int) $file_info['user_id'];
}

// Build a download URL for a customer's order
function myplugin_get_download_url( int $order_id, string $filename ): string {
    $token = myplugin_create_download_token( $order_id, $filename, get_current_user_id() );
    return home_url( '/download/' . $token . '/' );
}

NOTE: Run flush_rewrite_rules() once (e.g., on plugin activation) after registering the add_rewrite_endpoint() call. Store files above the webroot or in a directory protected by a deny from all .htaccess rule — never in a publicly accessible path, even with "unguessable" filenames.

Leave Comment

Your email address will not be published. Required fields are marked *