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.