Protecting Sensitive WordPress Media Files on Pantheon Hosting

Made on

Locking up media files behind an old door
DSC_1697 Wooden Door Locks West Stow Anglo Saxon Village Thetford Suffolk 26-06-2010” by rodtuk is licensed under CC BY-NC-SA 2.0.

Be cautious about storing sensitive files in the WordPress uploads folder.

Sensitive files include form submissions, member documents, private PDFs, WooCommerce downloads. Users should only access these files after logging in or meeting specific criteria. Your first instinct as a WordPress developer might be to reach for a plugin like “Prevent Direct Access” or similar file protection plugins.

That won’t work as expected on Pantheon.

File protection plugins typically rely on .htaccess rules to block direct file access, but Pantheon runs on nginx and completely ignores .htaccess files. The plugin will install, activate, and appear to work – but it provides zero actual protection. Anyone with a direct link can download your “protected” files.

This guide explains how to actually protect WordPress media files on Pantheon using the platform’s built-in tools, when you need Cloudflare as a backup plan, and how to serve protected files to authorized users via PHP.

Why WordPress File Protection Plugins Don’t Work on Pantheon

Most WordPress file protection plugins follow the same approach: modify .htaccess to block HTTP requests to specific files or directories, then serve those files through PHP after checking user permissions.

Here’s a typical .htaccess rule from a protection plugin:

apache

<FilesMatch "\.(pdf|docx|zip)$">
  Order Deny,Allow
  Deny from all
</FilesMatch>

The problem: Pantheon uses nginx as its web server, not Apache. From Pantheon’s documentation: “nginx does not recognize or parse Apache’s directory-level configuration files, known as .htaccess files; it’s like they don’t even exist.”

Even if you create or upload an .htaccess file to your Pantheon site, nginx will ignore it completely. Your “protected” files remain publicly accessible to anyone who knows or guesses the URL.

Similarly, Pantheon doesn’t allow direct nginx.conf modifications. The nginx configuration is managed at the platform level and standardized across all sites for performance and security reasons.

This architectural decision makes Pantheon faster and more efficient, but it means traditional WordPress file protection approaches won’t work.

The Pantheon Solution: protected_web_paths in pantheon.yml

Pantheon provides a platform-level solution for blocking direct HTTP access to files: the protected_web_paths directive in your pantheon.yml configuration file.

This approach blocks web requests at the platform level (before they reach WordPress) while still allowing your PHP code to read the files from the filesystem.

Basic Configuration

Add protected paths to your pantheon.yml file in your site’s repository root:

yaml

api_version: 1

protected_web_paths:
  - /wp-content/uploads/gravity-forms-uploads/
  - /wp-content/uploads/private-documents/
  - /wp-content/uploads/member-files/

When someone tries to access https://yoursite.com/wp-content/uploads/private-documents/secret.pdf via their browser, Pantheon returns a 403 Forbidden error. However, your WordPress PHP code can still read /wp-content/uploads/private-documents/secret.pdf using standard filesystem functions.

How protected_web_paths Works

The protection happens at nginx level, before WordPress even processes the request:

  1. User requests https://example.com/wp-content/uploads/private-documents/file.pdf
  2. Pantheon’s nginx checks protected_web_paths configuration
  3. Path matches protected directory
  4. nginx returns 403 Forbidden immediately
  5. Request never reaches WordPress PHP layer

Your WordPress code can still access these files:

php

$file_path = wp_upload_dir()['basedir'] . '/private-documents/file.pdf';
if (file_exists($file_path)) {
    $contents = file_get_contents($file_path);
}

Important Limitations

No wildcards or regex patterns:
You must specify exact paths. Pantheon doesn’t support patterns like /wp-content/uploads/*.pdf or regex matching.

yaml

# This works
protected_web_paths:
  - /wp-content/uploads/private-documents/

# This does NOT work
protected_web_paths:
  - /wp-content/uploads/*.pdf  # No wildcards
  - /wp-content/uploads/private-*/  # No wildcards

Maximum 24 paths:
You can protect up to 24 specific files or directories. If you need more granular control, consider protecting entire directories rather than individual files.

Case-sensitive paths:
Paths must match exactly, including capitalization:

yaml

protected_web_paths:
  - /wp-content/uploads/Private-Documents/  # Won't match /private-documents/

Changes take 5-10 seconds:
After committing pantheon.yml changes, wait 5-10 seconds for the configuration to propagate before testing.

Practical Examples

Protect Gravity Forms uploads:

yaml

api_version: 1
protected_web_paths:
  - /wp-content/uploads/gravity_forms/

Protect WooCommerce customer uploads:

yaml

api_version: 1
protected_web_paths:
  - /wp-content/uploads/woocommerce_uploads/
  - /wp-content/uploads/customer-documents/

Protect membership plugin private content:

yaml

api_version: 1
protected_web_paths:
  - /wp-content/uploads/members/
  - /wp-content/uploads/premium-content/

Cloudflare: The Last Resort

Pantheon’s protected_web_paths works well for protecting specific directories, but its limitations become problematic in certain scenarios:

Pattern matching required:
You need to protect files matching specific patterns (all PDFs, files with “confidential” in the filename, files in date-stamped subdirectories).

More than 24 paths:
You have dozens of form upload directories or user-specific folders that exceed the 24-path limit.

Complex conditional logic:
Protection rules based on file extensions, user agents, referrers, or other HTTP headers.

In these cases, you can use Cloudflare Workers or WAF rules as an additional layer on top of Pantheon. However, this adds complexity and another point of failure.

Cloudflare WAF Rule Example

If you need to block direct access to PDFs with “private” in the filename:

  1. Stack Cloudflare in front of Pantheon (Cloudflare → Pantheon)
  2. Create a WAF Custom Rule:

Rule Expression:

(http.request.uri.path contains "/wp-content/uploads/" and http.request.uri.path contains "private" and http.request.uri.path contains ".pdf")

Action: Block or redirect to 404 page

This gives you pattern-matching capabilities that pantheon.yml doesn’t support, but it requires managing an additional service and understanding Cloudflare’s rule syntax.

Serving Protected Files to Authorized Users

Blocking direct access is only half the solution. You need a way for authorized users to download these files through WordPress after authentication.

Create a Download Handler

Create a file download handler that checks user permissions before serving files:

php

<?php
/**
 * Protected File Download Handler
 * 
 * Place in wp-content/mu-plugins/protected-downloads.php
 */

add_action('init', 'handle_protected_file_download');

function handle_protected_file_download() {
    // Check if this is a download request
    if (!isset($_GET['protected_file'])) {
        return;
    }
    
    // Security: User must be logged in
    if (!is_user_logged_in()) {
        auth_redirect();
        exit;
    }
    
    // Get the requested file path
    $file_param = $_GET['protected_file'];
    
    // Security: Prevent directory traversal attacks
    $file_param = str_replace(['..', '\\'], '', $file_param);
    
    // Build full file path
    $upload_dir = wp_upload_dir();
    $file_path = $upload_dir['basedir'] . '/' . $file_param;
    
    // Verify file exists and is within uploads directory
    if (!file_exists($file_path) || strpos(realpath($file_path), realpath($upload_dir['basedir'])) !== 0) {
        wp_die('File not found.', 'Error', array('response' => 404));
    }
    
    // Optional: Add additional permission checks here
    // For example, check user role, membership level, etc.
    if (!current_user_can('read')) {
        wp_die('You do not have permission to access this file.', 'Forbidden', array('response' => 403));
    }
    
    // Get file information
    $file_name = basename($file_path);
    $file_size = filesize($file_path);
    $mime_type = mime_content_type($file_path);
    
    // Clear any output buffers
    if (ob_get_level()) {
        ob_end_clean();
    }
    
    // Set headers for file download
    header('Content-Type: ' . $mime_type);
    header('Content-Disposition: attachment; filename="' . $file_name . '"');
    header('Content-Length: ' . $file_size);
    header('Cache-Control: private, max-age=0, must-revalidate');
    header('Pragma: public');
    
    // Support for resumable downloads
    header('Accept-Ranges: bytes');
    
    // Serve the file
    readfile($file_path);
    exit;
}

Generate Protected Download Links

Instead of linking directly to files in wp-content/uploads, generate download URLs that go through your handler:

php

<?php
// Direct link (blocked by protected_web_paths)
$direct_url = $upload_dir['baseurl'] . '/private-documents/report.pdf';

// Protected download link (goes through PHP handler)
$download_url = home_url('/?protected_file=private-documents/report.pdf');

// Use in your template
echo '<a href="' . esc_url($download_url) . '">Download Report</a>';

Add Role-Based Permissions

Extend the handler to check user roles or membership levels:

php

// In the download handler, after checking if user is logged in
$current_user = wp_get_current_user();

// Check if user has specific role
if (!in_array('subscriber', $current_user->roles) && !in_array('administrator', $current_user->roles)) {
    wp_die('You must be a subscriber to download this file.', 'Forbidden', array('response' => 403));
}

// Or check custom capability
if (!current_user_can('download_private_files')) {
    wp_die('Insufficient permissions.', 'Forbidden', array('response' => 403));
}

Integration with Popular Plugins

Gravity Forms:

php

add_filter('gform_upload_path', 'protect_gravity_forms_uploads', 10, 2);

function protect_gravity_forms_uploads($path, $form_id) {
    // Store uploads in protected directory
    $upload_dir = wp_upload_dir();
    return $upload_dir['basedir'] . '/gravity-forms-uploads/form-' . $form_id . '/';
}

WooCommerce:

php

add_filter('woocommerce_file_download_path', 'serve_protected_woocommerce_file', 10, 3);

function serve_protected_woocommerce_file($file_path, $email_address, $order) {
    // Verify order belongs to current user
    if (!is_user_logged_in() || $order->get_user_id() !== get_current_user_id()) {
        return false;
    }
    
    // Return path to protected file
    return $file_path;
}
Free consultation

Or need WordPress support? We’ve completed 50+ migrations and can help you avoid the common pitfalls.

Real-World Implementation Example

Let’s walk through a complete example: protecting form uploads from a Gravity Forms contact form that collects resumes.

Step 1: Configure Protected Path

Add to pantheon.yml:

yaml

api_version: 1
protected_web_paths:
  - /wp-content/uploads/gravity_forms/

Commit and deploy:

bash

git add pantheon.yml
git commit -m "Protect Gravity Forms uploads"
git push origin master

Step 2: Create Download Handler

Create /wp-content/mu-plugins/resume-downloads.php:

php

<?php
/**
 * Protected Resume Download Handler
 */

add_action('init', 'handle_resume_download');

function handle_resume_download() {
    if (!isset($_GET['download_resume'])) {
        return;
    }
    
    // Only HR role can download resumes
    if (!current_user_can('edit_posts')) {
        wp_die('Access denied. Only HR staff can download resumes.');
    }
    
    $file_id = intval($_GET['download_resume']);
    $file_path = get_attached_file($file_id);
    
    if (!$file_path || !file_exists($file_path)) {
        wp_die('Resume not found.', 'Error', array('response' => 404));
    }
    
    // Verify this is actually a resume upload
    $upload_dir = wp_upload_dir();
    $gravity_forms_dir = $upload_dir['basedir'] . '/gravity_forms/';
    
    if (strpos(realpath($file_path), realpath($gravity_forms_dir)) !== 0) {
        wp_die('Invalid file.', 'Error', array('response' => 403));
    }
    
    if (ob_get_level()) {
        ob_end_clean();
    }
    
    header('Content-Type: application/pdf');
    header('Content-Disposition: attachment; filename="' . basename($file_path) . '"');
    header('Content-Length: ' . filesize($file_path));
    
    readfile($file_path);
    exit;
}

Step 3: Generate Download Links

In your admin panel or notification emails:

php

$attachment_id = 12345; // From Gravity Forms entry
$download_url = home_url('/?download_resume=' . $attachment_id);
echo '<a href="' . esc_url($download_url) . '">Download Resume</a>';

Step 4: Test Protection

  1. Log out of WordPress
  2. Try accessing file directly: https://yoursite.com/wp-content/uploads/gravity_forms/resume.pdf
  3. Result: 403 Forbidden ✓
  4. Try download link while logged out: https://yoursite.com/?download_resume=12345
  5. Result: Redirected to login page ✓
  6. Log in as HR user, use download link
  7. Result: File downloads successfully ✓

Troubleshooting Common Issues

“Files still publicly accessible after adding protected_web_paths”

Check pantheon.yml syntax:
Invalid YAML will be silently ignored. Use a YAML validator to verify syntax.

Wait 10 seconds:
Configuration changes aren’t instant. Wait 5-10 seconds after deployment before testing.

Verify exact path:
Paths are case-sensitive and must start with /. Check your actual upload directory structure.

Clear CDN cache:
If using Cloudflare or another CDN, clear the cache for affected URLs.

“PHP can’t read protected files”

This shouldn’t happen – protected_web_paths only blocks HTTP requests, not filesystem access. Verify:

php

$file_path = wp_upload_dir()['basedir'] . '/private-documents/test.pdf';
error_log('File exists: ' . (file_exists($file_path) ? 'yes' : 'no'));
error_log('Readable: ' . (is_readable($file_path) ? 'yes' : 'no'));

If files aren’t readable, check file permissions and ownership on the Pantheon environment.

“Download handler causes memory errors”

For large files, use chunked reading instead of readfile():

php

function serve_file_chunked($file_path) {
    $chunk_size = 8192; // 8 KB chunks
    $handle = fopen($file_path, 'rb');
    
    if ($handle === false) {
        return false;
    }
    
    while (!feof($handle)) {
        $chunk = fread($handle, $chunk_size);
        echo $chunk;
        flush();
    }
    
    fclose($handle);
    return true;
}

Comparing Approaches

ApproachProsConsBest For
pantheon.yml protected_web_pathsPlatform-level protection, no overhead, simple configurationLimited to 24 paths, no wildcards, directory-level onlyMost use cases, protecting upload directories
Cloudflare WAF RulesPattern matching, unlimited rules, additional DDoS protectionAdded complexity, another service to manage, potential failure pointComplex patterns, need more than 24 rules
WordPress PluginEasy UI, role-based access, loggingDoesn’t work on PantheonNot recommended for Pantheon
PHP-only approachMaximum flexibility, fine-grained controlOverhead on every request, complex to maintainWhen you need per-file permissions

Conclusion

Protecting sensitive WordPress files on Pantheon requires a different approach than traditional WordPress hosting. Plugin-based solutions that rely on .htaccess won’t work because Pantheon uses nginx, not Apache.

Pantheon’s protected_web_paths directive provides simple, effective file protection at the platform level. It blocks direct HTTP access while allowing your PHP code to serve files to authorized users. This approach works for most scenarios where you need to protect upload directories – form submissions, member content, premium downloads.

When you need pattern matching or more than 24 protected paths, Cloudflare WAF rules offer additional flexibility as a last resort, though at the cost of added complexity.

Combine protected_web_paths with a simple PHP download handler to create a complete file protection system that checks user authentication, validates permissions, and serves files securely – all without the overhead or limitations of traditional WordPress plugins.


Knihter specializes in WordPress development on Pantheon, including complex security implementations, form integrations, and performance optimization. Contact us for WordPress support. We help agencies build secure, scalable WordPress sites that protect sensitive user data.

Related Services:

FAQ

Why don’t WordPress file protection plugins work on Pantheon?

Pantheon uses nginx as its web server, which doesn’t recognize or parse .htaccess files. Most WordPress file protection plugins rely on .htaccess rules to block direct access, so they have no effect on Pantheon. The plugins will install and activate but provide zero actual protection.

Can I use .htaccess files on Pantheon at all?

No. Pantheon completely ignores .htaccess files. Even if you create or upload one, nginx won’t read it. This is an architectural choice for performance – nginx processes requests faster by not checking for directory-level configuration files.

Does protected_web_paths block WordPress from accessing files?

No. The protected_web_paths directive only blocks HTTP requests at the nginx level. Your WordPress PHP code can still read protected files using normal filesystem functions like file_get_contents() or readfile().

How many files can I protect with protected_web_paths?

You can specify up to 24 paths in the protected_web_paths array. This includes both individual files and directories. If you need to protect more than 24 items, protect entire directories rather than individual files.

Can I use wildcards in protected_web_paths?

No. Pantheon doesn’t support wildcards, regex patterns, or glob matching in protected_web_paths. You must specify exact file paths or directory paths. If you need pattern matching, consider using Cloudflare WAF rules as an additional layer.

How long does it take for protected_web_paths changes to take effect?

Configuration changes typically take 5-10 seconds to propagate after you commit and deploy your pantheon.yml file. If protection doesn’t work immediately, wait a few seconds and try again.

Can I protect files outside wp-content/uploads?

Yes. The protected_web_paths directive works for any path relative to your docroot. You could protect files in /wp-content/themes/, custom directories, or anywhere else in your WordPress installation.

Do I need Cloudflare to protect files on WordPress?

No. For most use cases, protected_web_paths in pantheon.yml is sufficient. Only use Cloudflare WAF rules if you need pattern matching, more than 24 protected paths, or conditional logic that pantheon.yml doesn’t support.

Will this slow down my WordPress site performance?

The protected_web_paths approach has zero performance impact because protection happens at the nginx level before WordPress loads. Serving files through a PHP download handler does add overhead, but only for files that users actually download – regular page loads are unaffected.

Can I password-protect specific files instead of requiring WordPress login?

Not directly through protected_web_paths. You’d need to implement custom authentication in your PHP download handler. The protected_web_paths directive simply blocks all direct HTTP access – your PHP code determines who can access files through your download handler.