DEV Community

Cover image for WordPress path security: return 404 before PHP runs (Apache + nginx)
Calin V.
Calin V.

Posted on • Originally published at wpghost.com

WordPress path security: return 404 before PHP runs (Apache + nginx)

Every default WordPress install answers the same handful of requests at the same paths. That's what lets one script attack hundreds of thousands of sites: /wp-login.php, /wp-admin/, /?author=1, /xmlrpc.php, /wp-content/uploads/. Same coordinates everywhere.

Path security is moving or rejecting those predictable paths at the rewrite layer, so the request returns 404 before PHP loads. Not a PHP firewall that boots WordPress and then decides to block. The server's rewrite rules (Apache mod_rewrite, nginx location blocks) answer first.

It matters because the targeting is automated and fast. Per Patchstack's State of WordPress Security in 2026, 91% of the 11,334 vulns disclosed in 2025 were in plugins, and heavily-exploited ones are hit within 6h (20%), 24h (45%), and 7 days (70%) of disclosure. You reject the recon request, the exploit request behind it never arrives.

Two rules before touching anything: do the login-path move last, and verify every change from outside with curl, not from a settings screen.

0. Watch what a bot sees

for p in "wp-login.php" "wp-admin/" "?author=1" "xmlrpc.php" "wp-content/uploads/"; do
  code=$(curl -s -o /dev/null -w "%{http_code}" "https://example.com/${p}")
  printf "%-22s -> %s\n" "$p" "$code"
done
Enter fullscreen mode Exit fullscreen mode

Whatever returns 200/301/302 is a coordinate the script already has. The goal below is to turn those into 404/403.

1. Kill directory listing (zero lockout risk, do it first)

If /wp-content/uploads/ has no index file, the server lists every file in it: PDFs, invoices, a stray DB export. Close it.

# .htaccess (Apache / LiteSpeed)
Options -Indexes
Enter fullscreen mode Exit fullscreen mode
# nginx server/location block
autoindex off;
Enter fullscreen mode Exit fullscreen mode

Verify:

curl -s -o /dev/null -w "%{http_code}\n" "https://example.com/wp-content/uploads/"  # want 403, not a file list
Enter fullscreen mode Exit fullscreen mode

2. Reject path traversal and sensitive-file requests

Path traversal (CWE-22) uses ../ sequences to climb out of a plugin's directory and read files like wp-config.php. You can't patch someone else's plugin from here, but you can reject the malformed request at the edge of your own server so it never reaches the plugin's PHP.

# .htaccess: block encoded/literal traversal and dotfile probes
RewriteEngine On
RewriteCond %{QUERY_STRING} (\.\./|\.\.%2f|%2e%2e) [NC]
RewriteRule ^ - [F]

# deny direct hits on sensitive files
<FilesMatch "^(wp-config\.php|readme\.html|license\.txt|xmlrpc\.php)$">
  Require all denied
</FilesMatch>
Enter fullscreen mode Exit fullscreen mode
location ~* (\.\./|\.\.%2f|%2e%2e) { return 403; }
location = /xmlrpc.php { deny all; }
location = /readme.html { deny all; }
Enter fullscreen mode Exit fullscreen mode

This is surface reduction, not a patch. It buys the window until the plugin update lands. Update anyway.

3. Suppress full path disclosure

An error that prints /home/user/public_html/wp-content/... hands a traversal attempt its map. Turn off public debug output so error paths never render.

// wp-config.php
define('WP_DEBUG', false);
define('WP_DEBUG_DISPLAY', false);
@ini_set('display_errors', 0);
Enter fullscreen mode Exit fullscreen mode

And make sure the log itself isn't served:

<Files debug.log>
  Require all denied
</Files>
Enter fullscreen mode Exit fullscreen mode

4. Close author enumeration

/?author=1 redirects to the author slug (a real username), and the REST API lists users. A known username makes credential stuffing easier, and 88% of web-app attacks used stolen credentials (Verizon 2025 DBIR).

# .htaccess
RewriteCond %{QUERY_STRING} (^|&)author=([0-9]+) [NC]
RewriteRule ^ - [F]
Enter fullscreen mode Exit fullscreen mode
// close the REST users route for anonymous requests
add_filter('rest_endpoints', function ($e) {
    unset($e['/wp/v2/users'], $e['/wp/v2/users/(?P<id>[\d]+)']);
    return $e;
});
Enter fullscreen mode Exit fullscreen mode

5. Move the login path (LAST, with a rollback staged)

This is the highest-impact change and the one most likely to lock you out, because plugins generate their own login links (WooCommerce account pages, membership flows, password-reset emails, builder previews). Miss one reference and you get a redirect loop.

Stage the rollback before you make the change:

cp .htaccess .htaccess.bak
# recovery if you lock yourself out:
#   restore over SFTP/SSH, then flush caches
#   mv .htaccess.bak .htaccess && wp cache flush
Enter fullscreen mode Exit fullscreen mode

The mechanism: route your chosen path to the login handler and return 404 for the default.

# .htaccess (illustrative; keep every generated login link in sync)
RewriteRule ^secret-door/?$ /wp-login.php [QSA,L]
RewriteCond %{REQUEST_URI} ^/(wp-login\.php|wp-admin)(/|$) [NC]
RewriteCond %{HTTP_COOKIE} !wordpress_logged_in [NC]
RewriteRule ^ - [R=404,L]
Enter fullscreen mode Exit fullscreen mode

nginx equivalent lives in the server config and needs a reload:

location = /secret-door { try_files /wp-login.php =404; }
location ~* ^/(wp-login\.php|wp-admin) {
  # allow logged-in sessions through your real path; 404 the rest
  return 404;
}
Enter fullscreen mode Exit fullscreen mode

Doing this by hand means owning every hard-coded reference and re-testing on each plugin update, which is exactly why many people hand the rewrites to a login-path/hardening plugin instead. Either way, keep the old admin tab open until the new path is confirmed.

Re-run the baseline

curl -s -o /dev/null -w "wp-login.php  -> %{http_code}\n" "https://example.com/wp-login.php"   # 404
curl -s -o /dev/null -w "wp-admin/     -> %{http_code}\n" "https://example.com/wp-admin/"       # 404/302 to 404
curl -s -o /dev/null -w "author=1      -> %{http_code}\n" "https://example.com/?author=1"        # 403
curl -s -o /dev/null -w "uploads/      -> %{http_code}\n" "https://example.com/wp-content/uploads/" # 403
curl -s -o /dev/null -w "xmlrpc.php    -> %{http_code}\n" "https://example.com/xmlrpc.php"        # 403
Enter fullscreen mode Exit fullscreen mode

"Isn't this security through obscurity?"

No. Obscurity leaves the resource in place and bets nobody looks hard enough. A 404 at the rewrite layer means the request never reaches code that can act on it: the login handler doesn't run, the plugin endpoint doesn't load. The automated chain depends on predictable coordinates, so removing the coordinates breaks it at step one. That's deleting attack surface, not covering it.

And it doesn't replace a scanner. Path security breaks recon; it doesn't disinfect an infected site. If the baseline in step 0 already looked wrong, clean first, then harden.

On nginx-managed hosting, how do you handle the config reload for path rules without shell access — host automation, a plugin that writes the vhost, or something else? Curious what's actually working for people on SiteGround/Kinsta-style setups.

Top comments (0)