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:
- User requests
https://example.com/wp-content/uploads/private-documents/file.pdf - Pantheon’s nginx checks protected_web_paths configuration
- Path matches protected directory
- nginx returns 403 Forbidden immediately
- 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:
- Stack Cloudflare in front of Pantheon (Cloudflare → Pantheon)
- 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;
}

Planning a WordPress migration to Pantheon?
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
- Log out of WordPress
- Try accessing file directly:
https://yoursite.com/wp-content/uploads/gravity_forms/resume.pdf - Result: 403 Forbidden ✓
- Try download link while logged out:
https://yoursite.com/?download_resume=12345 - Result: Redirected to login page ✓
- Log in as HR user, use download link
- 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
| Approach | Pros | Cons | Best For |
|---|---|---|---|
| pantheon.yml protected_web_paths | Platform-level protection, no overhead, simple configuration | Limited to 24 paths, no wildcards, directory-level only | Most use cases, protecting upload directories |
| Cloudflare WAF Rules | Pattern matching, unlimited rules, additional DDoS protection | Added complexity, another service to manage, potential failure point | Complex patterns, need more than 24 rules |
| WordPress Plugin | Easy UI, role-based access, logging | Doesn’t work on Pantheon | Not recommended for Pantheon |
| PHP-only approach | Maximum flexibility, fine-grained control | Overhead on every request, complex to maintain | When 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
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.
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.
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().
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.
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.
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.
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.
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.
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.
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.
