How to serve protected files after login with PHP in WordPress

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

  1. Prevent direct HTTP access to the files (deny via web server or store files outside webroot).
  2. Create a PHP endpoint that authenticates the request, checks authorization, and then serves the file.
  3. 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.
  4. 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 directory

    Require 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

  1. Try accessing the file directly by guessable URL — should be blocked (403 or 404).
  2. Hit the protected endpoint as an anonymous user — should be rejected.
  3. Login as permitted user and test downloading via the endpoint.
  4. If using X-Sendfile/X-Accel, check web server error logs for mapping or permission problems.
  5. 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/.htaccess

    Require 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 🙂



Leave a Reply

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