A brute-force run against WordPress looks like the same request to wp-login.php repeating hundreds of times from one address, usually with a scripted user agent and a response that never changes size. You do not need a security plugin to see it. You need the raw access log, three shell commands, and about five minutes.
I open the access log before any dashboard, because a dashboard tells you what it chose to record and the log tells you what the server received.
The short version
- Log fields are positional. Field 1 is the IP, field 7 is the requested path, field 9 is the status code, field 10 is bytes sent.
- One line means nothing. Two hundred of the same line from one IP inside an hour means everything.
POST /wp-login.phpreturning 200 is a rejected attempt. Returning 302 means somebody got through.- Count before you block, and prefer hardening the endpoint over banning the address — botnets rotate IPs faster than you can write firewall rules.
- Read the rotated files too. Attacks straddle midnight, and the
.gzfrom yesterday holds the half you are missing.
One line, read left to right
Apache and nginx both default to the combined log format. Fields are separated by spaces, and the quoted parts can contain spaces of their own, which is why the request is wrapped in quotes as a single unit.
203.0.113.45 - - [01/Nov/2026:04:12:33 +0800] "POST /wp-login.php HTTP/1.1" 200 4312 "-" "python-requests/2.31.0"
Read it as a sentence. At 04:12 in the morning, an address that sent no referrer and identified itself as a Python script posted to the login form, and the server answered with a 200 and 4,312 bytes. Nothing about that line belongs to a person.
| Field | In the line above | Why it matters |
|---|---|---|
| 1 | 203.0.113.45 | Who. Group by this first. |
| 4–5 | 01/Nov/2026:04:12:33 | When. Real customers sleep. |
| 6–8 | POST /wp-login.php | What they asked for. Method plus path is the whole story. |
| 9 | 200 | Result. 200 means rejected, 302 means logged in. |
| 10 | 4312 | Bytes sent. Hundreds of identical counts means nothing changed. |
| 12 | python-requests/2.31.0 | Client. Browsers do not introduce themselves as Python. |
The IP above is from the documentation range, not a live log.
Where the file actually lives
This is the step most people give up at.
cPanel: /home/<user>/access-logs/<domain.com>. It is a symlink to the real domlogs file, and it is readable by your account, which is all you need. The error log sits in ~/logs/. If you are in File Manager rather than SSH, turn on hidden files, because the directory is easy to miss next to the public_html tree.
Plesk: /var/www/vhosts/system/<domain>/logs/. Inside it you get access_log, access_ssl_log, proxy_access_log and the matching error logs. On Plesk, nginx sits in front of Apache and sees the real client IP, so proxy_access_log is the file you want when access_log shows a loopback address.
Plain nginx: /var/log/nginx/access.log, with access.log.1 and gzipped archives beside it. Rotated files end in .gz, so read them with zcat instead of cat — a normal attack runs for hours and will be split across two files.
The four patterns worth naming
Not every 404 is an attack and not every bot is hostile. These four are the ones I act on.
1. The login drumbeat. Hundreds of POST /wp-login.php lines from one address, all 200, all roughly the same byte count. The 200 is the point: WordPress re-renders the form with an error, so a rejected attempt and a served form share a status. Identical byte counts are the giveaway.
2. The 302 that follows. A POST /wp-login.php answered with 302 is a successful authentication — WordPress redirects to /wp-admin/ on success and only returns 200 on failure. If the timestamp is 3 a.m. and the IP is not yours, assume the password is already known to someone else. Rotate it before you do anything else, then run the five-minute infection check.
3. XML-RPC as an amplifier. POST /xmlrpc.php with system.multicall lets a client try hundreds of passwords in a single HTTP request. It barely registers in request counts and hammers the CPU, which is also how small accounts end up suspended for resource abuse. If you do not publish from a desktop client or the WordPress app, turning XML-RPC off removes the whole class of attack.
4. Reconnaissance before the attempt. GET /?author=1 through ?author=9 enumerates usernames. So does /wp-json/wp/v2/users. A scattering of 404s against plugin folders you never installed is somebody running a vulnerability scanner against your site. None of it breaks anything by itself; all of it is a preview of what comes next.
Counting, not squinting
You cannot eyeball ten thousand lines. Three commands answer almost every question I have. Run them from the directory that holds the log.
# top talkers, all traffic
awk '{print $1}' access_log | sort | uniq -c | sort -rn | head -20
# who is hitting the login form
grep 'POST /wp-login.php' access_log | awk '{print $1}' | sort | uniq -c | sort -rn | head
# bursts: requests per IP per minute
awk '{print $1, substr($4,2,17)}' access_log | sort | uniq -c | sort -rn | head -20
# did anything actually succeed?
awk '$9 == 302 && /POST \/wp-login\.php/ {print $1, $4}' access_log
The third command separates a bot from a person. A visitor loads a page, an image, a script, then leaves — a handful of requests in a minute, all different paths. An attacker sends the same path over and over. More than a few dozen requests to one endpoint in one minute is not a customer.
Save the output before you change anything. Logs rotate on a schedule you did not set, and the evidence you need for a host support ticket disappears with them.
Blocking it without breaking your own site
Blocking an IP is the weakest option and the one most people reach for first. A credential-stuffing run comes from hundreds of addresses; you can ban them one at a time until you run out of patience. Do the durable things first.
- Rate limit the endpoint at the edge. One rule in Cloudflare — more than a handful of POSTs to your login path per minute from one IP, block for an hour — stops the drumbeat before it reaches PHP.
- Two-factor authentication on every administrator. It converts a successful password guess into a failed login, which is the only fix that survives a leaked password.
- fail2ban on a VPS. A jail watching the Apache or nginx access log for repeated 200s on
wp-login.phpwill ban automatically and expire the ban on its own. - .htaccess as a last resort. If you must deny an address by hand, wrap the rule so you do not wipe out normal access, and whitelist yourself first.
<RequireAll>
Require all granted
Require not ip 203.0.113.45
</RequireAll>
On cPanel, the IP Deny Manager does the same thing with a form. Either way, add your own address and your uptime monitor to the allow list before you add anything to the deny list, or you will lock yourself out of the admin you were trying to protect. If the log already shows a 302, the ban is housekeeping — change the password, check for new administrator accounts, and treat the site as compromised until you have looked.
Ten minutes, the next time you get an alert
- Open the access log, not the dashboard.
- Run the top-talkers command and look for one address far ahead of the rest.
- Grep the login path and count per IP.
- Check for any 302 on a login POST. If it exists, rotate the password now.
- Check
POST /xmlrpc.phptraffic and disable XML-RPC if you do not use it. - Look for
?author=sweeps and/wp-json/wp/v2/usersrequests. - Read yesterday’s
.gzlog for the same address. - Only then add a block, and only after you have hardened the endpoint.
Do one thing today: run the top-talkers command on your busiest site and write the normal number down. You cannot recognise abnormal traffic until you have seen a quiet day. For the logs that matter when something breaks rather than attacks, this is the map I use.









