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.

 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:

 $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 *