DEV Community

Cover image for Prevent WordPress hacks by reading your own access log
Calin V.
Calin V.

Posted on • Originally published at wpghost.com

Prevent WordPress hacks by reading your own access log

WordPress hack prevention is not a product you install after a breach. It is the boring work you do before one, and almost all of it is a reaction to traffic that is already hitting your site right now. Open your access log and you can watch the first stage of nearly every WordPress compromise happen in real time: a bot confirming you run WordPress, listing your plugins, and testing your login. This post reads that log line by line, then breaks the attack chain stage by stage with configuration you already control.

I run WordPress security across a portfolio of sites at Squirrly, and the single most useful habit I picked up is treating the access log as a live feed of the reconnaissance stage instead of a forensic artifact I only open after something goes wrong. Detection tools tell you an attack happened. The log tells you it is happening, which is the only time prevention is cheap.

The three-stage chain, and why the first stage is the one to break

Automated WordPress attacks almost always run the same three stages: reconnaissance, then exploitation, then persistence. Reconnaissance is the bot requesting predictable paths to confirm WordPress and enumerate versions. Exploitation is a stolen credential or an unpatched vulnerability. Persistence is the backdoor it leaves so it can come back. Three stages means three places to cut the chain, and the cheapest cut is the first one, because a probe that returns nothing useful never advances to exploitation.

The reason prevention beats a patch-faster strategy: the exploitation window has collapsed. In Patchstack's State of WordPress Security in 2026, among heavily exploited vulnerabilities, 20% were attacked within six hours of public disclosure, 45% within 24 hours, and 70% within seven days. Highly exploitable vulnerabilities rose 113% year over year. You cannot hand-patch faster than a six-hour window across every plugin you run. You can make sure the vulnerable thing is not reachable in the first place.

Read the reconnaissance in your access log

Start by quantifying what is already probing you. On nginx combined logs, the request path is field 7, so this counts the top recon targets over whatever the log covers:

# Top probed WordPress paths
awk '{print $7}' /var/log/nginx/access.log \
  | grep -Ei 'wp-login|xmlrpc|wp-admin|author=|wp-json/wp/v2/users|wp-content/plugins' \
  | sort | uniq -c | sort -rn | head -20
Enter fullscreen mode Exit fullscreen mode

Then look specifically at credential pressure. A failed WordPress login is a POST /wp-login.php that returns 200 (the form re-renders); a success returns 302. So the busiest source IPs on 200-POSTs are your brute-force traffic:

# Top IPs hammering the login form (failed attempts)
grep 'POST /wp-login.php' /var/log/nginx/access.log \
  | awk '$9 == 200 {print $1}' \
  | sort | uniq -c | sort -rn | head
Enter fullscreen mode Exit fullscreen mode

On the sites I watch, those two commands routinely turn up hundreds to thousands of hits a day on a site nobody is deliberately targeting. That is the point worth internalizing: it is not personal. WordPress powers roughly 43% of the web (W3Techs), which makes it the largest automated-attack surface online, and Sophos/Hostinger figures put the number of WordPress sites hacked per day around 13,000. Bots probe by IP range, not by whether your content is worth stealing.

Stage one: make reconnaissance return nothing

The recon stage depends on predictability. The login is always at /wp-login.php, the admin at /wp-admin/, usernames leak from /?author=1 and the REST API. Change those defaults at the rewrite layer and the probe returns a 404 before PHP loads, so the site stops confirming itself as a target.

The lowest-risk wins first. Reject author enumeration and close the REST user list:

# .htaccess — reject /?author=N enumeration
RewriteEngine On
RewriteCond %{QUERY_STRING} (^|&)author=([0-9]+) [NC]
RewriteRule ^ - [F]
Enter fullscreen mode Exit fullscreen mode
// mu-plugin: drop the REST users endpoints for anonymous requests
add_filter('rest_endpoints', function ($endpoints) {
    unset($endpoints['/wp/v2/users']);
    unset($endpoints['/wp/v2/users/(?P<id>[\d]+)']);
    return $endpoints;
});
Enter fullscreen mode Exit fullscreen mode

Relocating the login path is the highest-impact recon cut, and also the one most likely to lock you out if you fumble it. Doing it purely by hand means maintaining rewrite rules plus patching every hard-coded reference (auth emails, cached pages, some plugins), which is why most people run a login-path/hardening plugin for this rather than owning the rewrites. Whichever route you take, keep the old session open and confirm the new URL works before you close the tab. This is where the tired "isn't that just security through obscurity?" objection shows up, and the honest answer is no: a 404 at the rewrite layer means the login code never executes. You are not covering a door, you are removing it from the map the bots are reading.

Stage two: break credential exploitation

Reconnaissance that survives leads straight to credentials, because that is where the actual break-ins are. The Verizon 2025 Data Breach Investigations Report found that 88% of Basic Web Application attacks involved stolen credentials, and that credential abuse opened 22% of all breaches. On WordPress that is the brute-force and credential-stuffing traffic your second log command surfaced.

Rate-limit the login at the server layer so the flood never reaches PHP. In nginx:

# http { } block
limit_req_zone $binary_remote_addr zone=wplogin:10m rate=20r/m;

# server { } block
location = /wp-login.php {
    limit_req zone=wplogin burst=5 nodelay;
    include fastcgi_params;
    fastcgi_pass unix:/run/php/php-fpm.sock;
}
Enter fullscreen mode Exit fullscreen mode

Then jail the repeat offenders with fail2ban so they stop consuming any resources at all:

# /etc/fail2ban/filter.d/wordpress-auth.conf
[Definition]
failregex = ^<HOST> .* "POST /wp-login\.php.*" 200
Enter fullscreen mode Exit fullscreen mode
# /etc/fail2ban/jail.d/wordpress.conf
[wordpress-auth]
enabled  = true
port     = http,https
filter   = wordpress-auth
logpath  = /var/log/nginx/access.log
maxretry = 5
findtime = 600
bantime  = 3600
Enter fullscreen mode Exit fullscreen mode

Rate-limiting and banning slow the noise. What actually neutralizes a stolen password is a second factor, so move real users off password-only logins. A passkey (FIDO2/WebAuthn) binds the cryptographic challenge to your domain, so a credential phished on a lookalike site cannot be replayed against yours. TOTP is a solid fallback where passkeys are not yet an option. The goal is simple: make a correct password insufficient on its own, because 88% of the attacks in the DBIR data are betting it is sufficient.

Stage three: make persistence hard, and detect what slips through

If exploitation still lands, the attacker wants to stay. Two config lines remove the easiest persistence routes:

// wp-config.php
define('DISALLOW_FILE_EDIT', true);  // no theme/plugin editor in the dashboard
define('DISALLOW_FILE_MODS', true);  // no plugin/theme install or update from the dashboard
Enter fullscreen mode Exit fullscreen mode

DISALLOW_FILE_MODS also blocks dashboard updates, so use it where you deploy code through git or CI rather than clicking Update. On sites where that is too strict, keep DISALLOW_FILE_EDIT alone.

The uploads directory is the classic backdoor drop. It should never execute PHP:

location ~* /wp-content/uploads/.*\.php$ { deny all; }
Enter fullscreen mode Exit fullscreen mode
# wp-content/uploads/.htaccess
<FilesMatch "\.php$">
  Require all denied
</FilesMatch>
Enter fullscreen mode Exit fullscreen mode

And a cron that flags PHP where PHP should not be turns persistence into something you notice in hours, not weeks:

# Alert on unexpected PHP under uploads
find wp-content/uploads -name '*.php' -type f -printf '%p\n'
Enter fullscreen mode Exit fullscreen mode

This is the seam where prevention hands off to scanning, and it is worth being honest about it.

The honest limit: prevention is not a scanner

Everything above reduces what an attacker can reach. None of it disinfects a site that is already compromised, and none of it catches malware that arrives through a channel you did not harden, like a legitimate admin session gone bad or a supply-chain update. That is a malware scanner's job. Prevention keeps a clean site clean; scanning is the safety net for what slips through. Run both, weighted toward prevention, and do not confuse one for the other.

There is a reason to weight toward prevention beyond philosophy. When Patchstack pentested common defences in 2025 (internal WAFs, Cloudflare, Imunify360, ModSecurity) against real WordPress exploits, those tools blocked only 12% of attacks on known-exploited vulnerabilities, rising to 26% on a broader test. The Hostinger/Sophos figure puts the share of WordPress exploits bypassing standard hosting firewalls at 87.8%. If your plan is "the host handles it," the data says the host is catching about a quarter. The cheapest quarter to add back is the one you cut at the recon stage, before anything is running.

A minimal prevention baseline

If you do nothing else this week, do these five, in order, verifying after each:

  1. Reject author enumeration and close the REST user endpoints.
  2. Rate-limit /wp-login.php at the server layer.
  3. Add fail2ban for repeat login offenders.
  4. Move admin accounts to a second factor (passkey, then TOTP).
  5. Deny PHP execution under wp-content/uploads/ and turn off the file editor. The surprising thing, every time I do this for a site that just got cleaned up, is how little of it is clever. It is an afternoon of config against traffic that was already in the log. The industry compilations put average WordPress hack recovery around $14,500 once you count downtime and lost rankings. The afternoon is cheaper.

What does your POST /wp-login.php count look like right now if you run that second command? Curious what a normal day looks like across other people's stacks, and what you rate-limit versus ban outright.

Top comments (0)