Migrate to RunxBuild and earn up to $50 in hosting credit on your first deposit.

Calculate your savings
unxBuild

Secure WordPress Media Files: The Uploads Folder Is Public

Sean

Platform Writer

Aug 30, 2026
8 min read

Attaching a file to a private post does not make the file private. It sits in wp-content/uploads with a predictable URL, served by the web server before WordPress is ever involved.

Secure WordPress Media Files: The Uploads Folder Is Public

This surprises people reasonably often, and occasionally expensively. A membership site puts its course PDFs behind a login, and the PDFs are one URL away from anyone. An invoice attached to a private order is readable by anyone who guesses the filename — and the filenames are rarely hard to guess.

The cause is structural: static files are served by Apache or Nginx directly from disk, and WordPress, which knows about permissions, never runs.

Table of contents

Why post privacy does not apply

When someone requests /wp-content/uploads/2026/08/report.pdf, the web server finds that file and returns it. No PHP runs. WordPress does not load. There is nothing in that path that knows what a logged-in user is.

Post visibility settings govern the post, not the files attached to it. A private post is hidden from the front end and its media are not.

The URLs are also more discoverable than people assume:

  • The date-based structure means the directory portion is predictable.
  • Directory listing is enabled on some servers, exposing everything in a folder.
  • The REST API’s media endpoint can expose attachment URLs.
  • Search engines index files linked from anywhere, and once indexed the URL is public even if you remove the link.
  • Sequential naming — invoice-1041.pdf — makes enumeration trivial.

Renaming files with random strings raises the bar and is not access control. It is security through obscurity, and it fails permanently the first time a URL is shared or indexed.

The working approach: move the files, gate the delivery

There is one architecture that actually works. Store private files outside the directly-served uploads directory, and serve them through PHP after a permission check.

// 1. Store outside the served path, e.g. WP_CONTENT_DIR . '/private-files/'

// 2. Serve through an endpoint that checks first
add_action( 'template_redirect', function () {
    if ( ! get_query_var( 'private_file' ) ) {
        return;
    }

    if ( ! is_user_logged_in() ) {
        auth_redirect();   // sends to login, returns here after
    }
    if ( ! current_user_can( 'read_private_files' ) ) {
        wp_die( 'Not authorised', 403 );
    }

    $name = basename( get_query_var( 'private_file' ) );
    $path = WP_CONTENT_DIR . '/private-files/' . $name;

    if ( ! is_readable( $path ) ) {
        wp_die( 'Not found', 404 );
    }

    header( 'Content-Type: ' . mime_content_type( $path ) );
    header( 'Content-Length: ' . filesize( $path ) );
    header( 'Content-Disposition: inline; filename="' . $name . '"' );
    header( 'X-Content-Type-Options: nosniff' );
    readfile( $path );
    exit;
} );

The basename() call is the security-critical line. Without it, a request for ../../../wp-config.php walks out of your directory and serves your database credentials. Never build a filesystem path from user input without stripping the directory portion.

The cost of this approach is that every download runs PHP, so large files consume a worker for the duration. X-Sendfile on Apache or X-Accel-Redirect on Nginx solves that — PHP performs the permission check and then hands the file off to the web server, which streams it efficiently.

Server rules as a second layer

If the files must stay under uploads, block them at the server and let PHP serve them:

# wp-content/uploads/private/.htaccess
Require all denied
location ^~ /wp-content/uploads/private/ {
    internal;   # only reachable via X-Accel-Redirect from PHP
}

The Nginx internal directive is particularly clean: the location cannot be requested from outside at all, but PHP can hand off to it with an X-Accel-Redirect header after checking permissions. You get the access control of PHP and the streaming performance of the web server.

Regardless of anything else, block PHP execution in uploads entirely:

# wp-content/uploads/.htaccess
<Files *.php>
  Require all denied
</Files>

This is the rule that turns a malicious upload from a shell into an inert file. It has no legitimate downside and belongs on every WordPress installation whether or not you have private files.

Signed URLs when PHP delivery is too slow

For large files or high volume, a time-limited signed URL is the standard pattern: WordPress checks permission and issues a URL valid for a short period, and the file is served from object storage or a CDN.

The trade-offs to be clear about:

  • Good: no PHP worker held during transfer, works well with a CDN, and the URL expires.
  • Bad: the URL is valid for anyone who has it during its lifetime, so it can be shared.
  • Mitigation: short expiry, and where the storage supports it, binding the signature to a client IP.

This is what most commercial download plugins do under the hood, and it is the right call when serving video or large archives. For a members-only PDF, PHP delivery with X-Sendfile is simpler and strictly more controlled.

The things people do that do not work

Worth naming explicitly, because they all appear in search results:

  • Random filenames. Obscurity. One shared link and it is public forever.
  • robots.txt disallow. A request not to index. It is publicly readable and does nothing about direct access — if anything it advertises the path.
  • Hiding the link in the UI. The file is still at its URL.
  • Password-protecting the post. Governs the post content only; attachments remain directly accessible.
  • Referrer checking. Trivially spoofed and breaks legitimate access from privacy-conscious browsers.
  • JavaScript-based gating. Runs after the file is already reachable, and is bypassed by anyone who views source.

The single test that distinguishes real protection from theatre: open the file URL directly in a private browsing window with no session. If the file downloads, it is public. Run that test on anything you believe is protected — it takes ten seconds and it is the only check that matters.

Doing the audit

The practical first step on an existing site is finding out what is actually exposed. Look for anything in uploads that should not be public — invoices, member documents, exports, database dumps left by a migration plugin.

Then check the permissions themselves: files should be 644 and directories 755, with wp-config.php tightened to 640 or 600. World-writable directories are both a vulnerability and a common cause of upload problems.

Managed WordPress on RunxBuild includes a file manager in the dashboard for browsing, editing, uploading and downloading, and a database browser for tables, rows and SQL. That makes an audit of what is sitting in wp-content/uploads something you do in a browser tab rather than a task that needs an SFTP client and a spare afternoon.

How this fits the rest of the stack

Files under wp-content/uploads are served by the web server with no permission check, so post privacy settings do not apply to them. Real protection means storing private files outside the served path and delivering them through a capability check — with basename() on any user-supplied filename, and X-Sendfile when performance matters. Test by opening the URL in a private window. The RunxBuild hosting calculator shows managed WordPress plans with the dashboard file manager included.

Useful related references:

FAQ

Are WordPress media files private if the post is private?

No. Post visibility governs the post, not its attachments. Files in wp-content/uploads are served directly by the web server before WordPress loads, so anyone with the URL can download them regardless of the post’s settings.

How do I protect files in wp-content/uploads?

Store private files outside the directly-served path and deliver them through a PHP endpoint that checks current_user_can() first. If they must stay under uploads, deny the directory at the server and serve via X-Sendfile or X-Accel-Redirect after the check.

Does renaming files with random names make them secure?

No, that is obscurity rather than access control. The URL still works for anyone who obtains it, permanently, and one shared or indexed link makes the file public. Use a real permission check.

Will robots.txt stop people downloading my files?

No. It is a request to search engines not to index, it is publicly readable, and it does nothing to prevent direct access. Listing a private path there arguably advertises it.

How do I check whether a file is actually protected?

Open its URL in a private browsing window with no session. If it downloads, it is public. That single test distinguishes genuine access control from every form of obscurity.

#wordpress media security#protected uploads#wp-content uploads#file permissions#WordPress