A client’s site got hacked. I did a clean reinstall, and it was hacked again the same day. Once I dug into the logs, the attack vector was brute-forcing wp-login.php. A clean install alone isn’t enough, you need defensive layers. Since then every site I stand up gets the same hardening. Here it is, layer by layer.
Layer 1: web server (.htaccess / nginx)
The first line of defence is filtering requests before they ever reach the app.
# hide wp-config.php
<Files wp-config.php>
Require all denied
</Files>
# block xmlrpc.php (pingback + brute force target)
<Files xmlrpc.php>
Require all denied
</Files>
# don't let php execute in uploads
<Directory "wp-content/uploads">
<FilesMatch ".(php|phtml|phar)$">
Require all denied
</FilesMatch>
</Directory>
# block user enumeration (?author=1)
RewriteCond %{QUERY_STRING} author=d
RewriteRule ^ - [L,R=403]
# hide readme.html (leaks WP version)
<Files readme.html>
Require all denied
</Files>For nginx the equivalents live in location blocks.
Layer 2: wp-config.php
WordPress’s own config file. A handful of constants are non-negotiable:
// disable file editing (theme/plugin editor)
define('DISALLOW_FILE_EDIT', true);
// disable plugin/theme install/update from admin (do it via CI/CD)
define('DISALLOW_FILE_MODS', true);
// route db errors to debug.log, not the page
define('WP_DEBUG', true);
define('WP_DEBUG_LOG', true);
define('WP_DEBUG_DISPLAY', false);
// auth keys must be generated (wp-cli)
// fresh set from https://api.wordpress.org/secret-key/1.1/salt/
// force SSL admin
define('FORCE_SSL_ADMIN', true);
// cap autosaves and revisions
define('WP_POST_REVISIONS', 5);DISALLOW_FILE_MODS is the critical one. Even if someone gets into admin, they can’t install a plugin. New plugins only go through WP-CLI or the deploy pipeline.
Layer 3: mu-plugins
Must-use plugins can’t be deactivated, which makes them the right home for security checks.
This theme ships [wp-content/mu-plugins/ac-security-hardening.php](wp-content/mu-plugins/ac-security-hardening.php) which:
- Closes the REST API
/usersendpoint to unauthenticated callers. - Strips
X-Powered-ByandServerheaders. - Locks down application passwords.
- Logs failed logins (for fail2ban).
- Hardens the
wp-json/oembedendpoints. - Disables pingback (XMLRPC) services.
// block rest api user enumeration
add_filter('rest_endpoints', function ($endpoints) {
if (isset($endpoints['/wp/v2/users'])) unset($endpoints['/wp/v2/users']);
if (isset($endpoints['/wp/v2/users/(?P<id>[d]+)'])) unset($endpoints['/wp/v2/users/(?P<id>[d]+)']);
return $endpoints;
});
// log failed logins
add_action('wp_login_failed', function ($username) {
error_log(sprintf('[LOGIN_FAIL] %s from %s', $username, $_SERVER['REMOTE_ADDR']));
});
// rate-limit the login endpoint
add_action('login_form_login', function () {
$ip = $_SERVER['REMOTE_ADDR'];
$key = 'ac_login_attempts_' . md5($ip);
$attempts = get_transient($key) ?: 0;
if ($attempts >= 5) wp_die('Too many attempts. Try again in 15 minutes.', 429);
set_transient($key, $attempts + 1, 15 * MINUTE_IN_SECONDS);
});Layer 4: user discipline
- Admin username must not be “admin”. Brute force always targets it.
- Enforce strong passwords.
- Two-factor authentication plugin, required for admin users.
- Rotate passwords every six months.
- Delete unused accounts.
- Trim capabilities with a role editor. Check whether an author actually needs delete_posts.
Layer 5: protection from outside
- Cloudflare or a WAF in front. It blocks known attack patterns before they reach the app.
- Fail2ban reading login logs to ban brute force IPs.
- SSH key-only access, password login disabled.
- Kernel kept current, PHP up to date.
- Backups, so when you do get hacked you can roll back to a clean version.
Hostinger note
These sites run with Hostinger’s own security mu-plugins. hostinger-auto-updates keeps core patched automatically. Leave that on, but keep full control over your own code on top.
Testing
- Run WPScan against your own site.
- Check sitecheck.sucuri.net.
- Run securityheaders.com for CSP, X-Frame-Options, Referrer-Policy.
- Review access logs regularly. Spot the anomalies (500 hits from one IP, a POST flood).
Takeaway
No single fix secures a site. Defense in depth does. Web server, WordPress config, mu-plugins, user management, outside protection. Each layer closes a different attack vector. If an attacker gets past one, the next one still holds. That’s engineering.