Contents
Overview
This article is a complete, detailed tutorial for serving protected files in WordPress with PHP after login. It covers recommended file storage patterns, server configuration to block direct access, secure WordPress endpoints to authenticate and authorize downloads, performance optimizations (X-Sendfile / X-Accel-Redirect), support for resume/partial requests, expiring signed URLs, and troubleshooting. Example code snippets are included for each step and follow the required code-block format.
Goals and threats
- Goal: Allow only authorized users (or users with specific capabilities) to download file assets that should not be publicly accessible.
- Threats to mitigate:
- Direct public access to private files via predictable URLs.
- Path traversal attacks or disclosure of filesystem paths.
- Leaky caching that exposes private content to proxies/CNDs.
- Slow PHP-based streaming for large files without server acceleration.
High-level approaches
- Prevent direct HTTP access to the files (deny via web server or store files outside webroot).
- Create a PHP endpoint that authenticates the request, checks authorization, and then serves the file.
- Prefer server-accelerated delivery (X-Sendfile for Apache, X-Accel-Redirect for Nginx) for large files fall back to chunked PHP streaming with proper headers and range support.
- Consider tokenized expiring URLs when sharing files externally or when you need short-lived public access.
Where to store protected files
- Outside webroot (recommended): Store under /var/www/protected_files/ or anywhere the webserver will not map to a public URL. PHP can read files from there and serve after checks.
- Under wp-content/uploads but blocked: If storing inside wp-content, put them in a subfolder and deny HTTP access with .htaccess or webserver rules.
- Use attachment IDs instead of raw paths: When possible, store files as attachments (or in a custom DB table) and reference by ID to avoid exposing file system paths in URLs.
Block direct access with web server config
Before serving anything via PHP, block direct access to the folder so nobody can hit the file by guessing a URL.
Apache .htaccess deny (Apache 2.2/2.4 aware)
# Deny direct HTTP access to this directoryRequire all denied Order deny,allow Deny from all
Alternative: store files outside the document root
If you place the files outside the webroot you have no HTTP-accessible URL at all PHP reads files from disk and uses X-Sendfile or streaming to deliver them. This is the safest option.
Option A — Simple PHP stream (fallback)
A straightforward approach: create a WordPress handler (endpoint) that checks authentication/authorization, validates the requested file id or path, and streams the file in chunks. This method works everywhere but is less efficient for very large files because PHP handles the I/O.
Key steps
- Use an ID or database reference instead of an arbitrary path from the request.
- Check is_user_logged_in() and/or current_user_can() and optionally a nonce.
- Set proper headers: Content-Type (via wp_check_filetype_and_ext), Content-Length, Content-Disposition.
- Support HTTP Range requests to allow resuming downloads.
- Stream in chunks (e.g., 1MB) and flush buffers to avoid high memory use.
Example: REST endpoint that streams a protected attachment (fallback streaming)
GET, callback => example_stream_protected_file, permission_callback => function() { return is_user_logged_in() }, )) }) function example_get_file_path_from_id(id){ // Use WordPress function to map attachment ID to path file = get_attached_file(id) return (file file_exists(file)) ? file : false } function example_stream_protected_file(request){ id = intval(request->get_param(id)) if(!id){ return new WP_Error(no_file, No file specified, array(status=>400)) } // Additional capability checks: if(!current_user_can(read)){ // tweak capability as needed return new WP_Error(forbidden, You do not have permission, array(status=>403)) } file = example_get_file_path_from_id(id) if(!file){ return new WP_Error(not_found, File not found, array(status=>404)) } // Determine mime type filetype = wp_check_filetype_and_ext(file, basename(file)) mime = !empty(filetype[type]) ? filetype[type] : application/octet-stream // Basic headers header(Content-Type: . mime) header(Content-Disposition: attachment filename= . basename(file) . ) header(Content-Length: . filesize(file)) header(Cache-Control: private, no-transform) // Disable execution time limit set_time_limit(0) // Stream the file in chunks chunkSize = 1024 1024 // 1MB handle = fopen(file, rb) if(handle === false){ return new WP_Error(read_error, Unable to open file, array(status=>500)) } // Close PHP session to prevent blocking other requests if(session_id()) { session_write_close() } while(!feof(handle)){ echo fread(handle, chunkSize) @ob_flush() @flush() } fclose(handle) exit // Stop WordPress from adding any extra output } ?>
Notes about the simple stream
- This does not implement HTTP Range requests — without ranges, users cannot resume downloads reliably.
- For large files, PHP streaming can be slow and consumes PHP worker time. Use X-Sendfile/X-Accel-Redirect where possible.
Option B — Prefer server acceleration: X-Sendfile (Apache) or X-Accel-Redirect (Nginx)
Using server-level accelerations delegates file I/O to the web server, which is much faster and does not consume PHP worker processes. The plugin or endpoint performs the authentication and then returns a special header pointing the web server to the internal file.
Apache: mod_xsendfile
# Apache config (example) LoadModule xsendfile_module modules/mod_xsendfile.so XSendFile on XSendFilePath /var/www/example/protected_files/
Nginx: X-Accel-Redirect internal alias
# Nginx site config snippet location /protected_files_internal/ { internal alias /var/www/example/protected_files/ } # The PHP script should send: header(X-Accel-Redirect: /protected_files_internal/relative/path/filename.ext)
Example: WordPress REST endpoint that uses X-Sendfile / X-Accel-Redirect with fallback
GET, callback => pfs_serve_file_with_accel, permission_callback => function() { return is_user_logged_in() }, )) }) function pfs_get_file_by_id(id){ file = get_attached_file(id) return (file file_exists(file)) ? file : false } function pfs_serve_file_with_accel(request){ id = intval(request->get_param(id)) if(!id){ return new WP_Error(no_file, No file specified, array(status=>400)) } if(!current_user_can(read)){ return new WP_Error(forbidden, Permission denied, array(status=>403)) } file = pfs_get_file_by_id(id) if(!file){ return new WP_Error(not_found, File not found, array(status=>404)) } // MIME headers ft = wp_check_filetype_and_ext(file, basename(file)) mime = !empty(ft[type]) ? ft[type] : application/octet-stream header(Content-Type: . mime) header(Content-Disposition: attachment filename=.basename(file).) header(Cache-Control: private, no-transform) // If Apache mod_xsendfile is available, send header if(function_exists(apache_get_modules)){ mods = apache_get_modules() if(in_array(mod_xsendfile, mods)){ header(X-Sendfile: . file) exit } } // If Nginx internal location is defined via constant (configure PFS_ACCEL_LOCATION) if(defined(PFS_ACCEL_LOCATION) PFS_ACCEL_LOCATION){ // Assumes alias mapping matches basename adjust if nested folders used header(X-Accel-Redirect: . PFS_ACCEL_LOCATION . / . basename(file)) exit } // Fallback to streaming (simple version) set_time_limit(0) fp = fopen(file, rb) if(!fp){ return new WP_Error(read_error, Unable to open file, array(status=>500)) } if(session_id()) session_write_close() while(!feof(fp)){ echo fread(fp, 10241024) @ob_flush() @flush() } fclose(fp) exit } ?>
How it works
- When mod_xsendfile is enabled, sending header X-Sendfile: /abs/path/to/file hands the file to Apache, which streams it efficiently.
- When Nginx is used, PHP sets header X-Accel-Redirect: /internal/location/relative/file and Nginx maps that to the filesystem (via internal/alias configuration).
- Fallback streaming ensures the endpoint still works on hosts without server acceleration.
Support resume / Range requests (important for big files)
Implementing HTTP Range support lets clients resume interrupted downloads. Below is a fairly complete PHP implementation that handles single-range requests and returns 206 Partial Content with appropriate Content-Range header.
Example: serve with Range support (core function)
end end >= size) { header(HTTP/1.1 416 Requested Range Not Satisfiable) header(Content-Range: bytes /size) exit } length = end - start 1 header(HTTP/1.1 206 Partial Content) header(Content-Range: bytes start-end/size) header(Content-Length: . length) } } else { header(Content-Length: . size) } // Stream the requested part fp = fopen(file_path, rb) if (!fp) { header(HTTP/1.1 500 Internal Server Error) exit } if (start) fseek(fp, start) set_time_limit(0) bufferSize = 1024 1024 // 1MB bytesRemaining = length if(session_id()) session_write_close() while(!feof(fp) bytesRemaining > 0) { read = (bytesRemaining > bufferSize) ? bufferSize : bytesRemaining data = fread(fp, read) echo data @ob_flush() @flush() bytesRemaining -= strlen(data) } fclose(fp) exit } ?>
Secure URL patterns: use IDs or signed tokens (do not accept raw file paths)
Never accept an arbitrary filesystem path from a query parameter. Use one of the following:
- Attachment ID or custom DB key: Map an internal numeric ID to a secured path. This avoids exposing paths and makes permissions easier.
- Signed URLs: If you must allow time-limited external access, generate a token (HMAC) with an expiry and validate it before serving.
Example: generate and validate signed expiring token
id, expires=>expires, sig=>sig), base) } // Validate in the endpoint function pfs_validate_signed_request(id, expires, sig){ if(time() > intval(expires)) return false secret = defined(PFS_SECRET) ? PFS_SECRET : (defined(AUTH_KEY) ? AUTH_KEY : change-this-secret) data = id . . expires calculated = hash_hmac(sha256, data, secret) return hash_equals(calculated, sig) } ?>
Plugin vs theme vs custom endpoint
- Plugin: Best choice. Keeps behavior portable and separate from theme changes. Implement REST routes or admin-post handlers in a plugin.
- Theme template: Avoid placing download logic in theme templates users changing themes may inadvertently lose protections.
- REST API: Clean and modern can reuse permission callbacks and is easy to call from JavaScript or server-side links.
- admin-post.php or admin-ajax.php: Useful when REST is not desired still possible to use same techniques.
Permissions and security checks
- Always call is_user_logged_in() or verify a valid signed token for guest access.
- Use current_user_can() to check a role or capability (e.g., download_files, custom capability).
- Validate inputs strictly (integers for IDs, whitelist file types if using filenames).
- Log suspicious access attempts (failed auth, forged tokens, path traversal tries).
- Set headers to avoid caching on shared proxies when files are private:
- Cache-Control: private, no-transform
- Pragma: no-cache (legacy)
Testing and debugging
- Try accessing the file directly by guessable URL — should be blocked (403 or 404).
- Hit the protected endpoint as an anonymous user — should be rejected.
- Login as permitted user and test downloading via the endpoint.
- If using X-Sendfile/X-Accel, check web server error logs for mapping or permission problems.
- Test large file downloads and resuming (use curl with Range header to test partial requests).
Troubleshooting common problems
- HTTP 500 on download: File permissions or fopen failure. Check PHP error log and file owner/permissions.
- X-Sendfile header ignored: mod_xsendfile not enabled, or XSendFilePath not set to include the file directory.
- Nginx X-Accel-Redirect returns 404: The internal alias path does not match or the location was not declared as internal.
- Partial downloads not working: Ensure the Range-handling code is present and that you do not send Content-Length for full file when using 206 responses incorrectly.
- Memory exhaustion: Use chunked streaming and ensure you dont read the entire file into memory (no file_get_contents for big files).
Example minimal .htaccess for protecting a WordPress uploads subfolder
# Place this in wp-content/uploads/protected/.htaccessRequire all denied Order deny,allow Deny from all
Example Nginx site config to serve internal protected files
server { # ... your existing config ... location /protected_files_internal/ { internal alias /var/www/example/protected_files/ } # other locations... }
Final recommendations and checklist
- Store files outside the webroot when possible.
- Never accept raw file paths from untrusted input.
- Use server acceleration (X-Sendfile/X-Accel) for performance on large files.
- Implement HTTP Range support to allow resumed downloads.
- Use attachment IDs or signed tokens for public short-lived links.
- Test with real users and monitor logs for unauthorized attempts.
Appendix: Useful snippets summary
.htaccess deny snippet
Require all denied Order deny,allow Deny from all
Apache mod_xsendfile enable example
LoadModule xsendfile_module modules/mod_xsendfile.so XSendFile on XSendFilePath /var/www/example/protected_files/
Nginx internal alias example
location /protected_files_internal/ { internal alias /var/www/example/protected_files/ }
Closing notes
Implementing protected file delivery in WordPress is a solvable problem with multiple acceptable approaches. For small sites or rare downloads, a simple authenticated PHP stream is fine. For production systems with large media and many downloads, prefer server-accelerated delivery (X-Sendfile/X-Accel-Redirect) with a secure REST or plugin endpoint to handle authentication and authorization. Always validate inputs, avoid exposing raw file paths, and use signed tokens for temporary public sharing.
|
Acepto donaciones de BAT's mediante el navegador Brave 🙂 |