WordPress DDoS protection: why the plugin arrives too late
By the time a plugin sees the request, it has already passed nginx and occupied a PHP-FPM worker. The real ceiling is pm.max_children, which is why a Layer 7 attack of 2.4 Mbit/s takes a WordPress site down.
By the time a WordPress plugin sees a request, it is already too late to stop a DDoS attack. At that point the request has passed the web server, occupied a PHP-FPM worker process and booted WordPress in full. The plugin then decides whether to reject it, and that decision costs exactly as much as serving a real page. The hard ceiling of a WordPress server is therefore not a firewall rule, it is pm.max_children, and that value is three digits or smaller on every server.
From that follows the number this whole article is built around: a Layer 7 attack of a few hundred requests per second against /?s=randomword takes a WordPress site down while the network link stays 99 percent empty. 500 requests per second at roughly 600 bytes each is 2.4 Mbit/s. On a 1 Gbit/s link that is one four-hundredth of the available bandwidth. This is exactly why operators go offline with a DDoS plugin active and a traffic graph that looks perfectly normal.
This article shows how a Layer 7 attack on WordPress actually works, how to tell it apart from brute force against wp-login.php and from a genuine traffic surge, which endpoints of a WordPress installation are expensive, how to pin the attack down using the PHP-FPM status page and the access log, and which configuration on the server actually works. After that comes the honest part: where those measures end, what a service in front such as Cloudflare or Sucuri delivers, and the five ways your origin IP becomes known anyway. If your site is down right now, jump to the section on a running attack and measure first instead of turning knobs.
Why a WordPress plugin cannot stop a DDoS attack
A security plugin is PHP code. PHP code runs in a PHP process. A PHP process is provided by PHP-FPM, and PHP-FPM keeps a fixed, limited number of them. That chain cannot be bypassed, and it explains the entire problem in three sentences.
The path of a request, and where the plugin sits
A request to a WordPress page passes through seven stations. Each one costs something, and the cost rises by roughly a factor of ten at every step.
- The kernel accepts the TCP packet. Cost: microseconds. Limited by the packet rate of the network card and the size of the accept queue.
- nginx accepts the connection and negotiates TLS. Cost: one asymmetric operation per new session, symmetric encryption after that. Limited by
worker_connectionsand the CPU. - nginx evaluates its own rules.
limit_req,limit_conn,deny, a hit in the page cache. Cost: a few microseconds. A request rejected here costs practically nothing. - nginx hands the request to PHP-FPM. From here on a worker process is occupied, for the entire duration of the request.
- PHP starts up. With OPcache enabled the compile step is gone, building the runtime environment remains.
- WordPress boots.
wp-load.php,wp-settings.php, the database connection, the options table, every active plugin, the theme. On an ordinary installation that is 40 to 120 milliseconds before the first line of your own code runs. - The security plugin evaluates the request and rejects it if needed.
Station 7 is the first point at which a plugin can do anything at all, and it sits behind the most expensive station. A request a plugin rejects has occupied the same worker process as a request that serves a page. For the attacker the difference is meaningless, because the goal is not the content of the response, it is the occupied worker.
There is one exception and it belongs here for completeness: Wordfence can place itself ahead of WordPress through the PHP directive auto_prepend_file in extended protection mode. Its rule set then runs at station 5 instead of station 7, that is, before the boot sequence. That saves the 40 to 120 milliseconds WordPress itself needs and is a real difference compared with an ordinary plugin. The worker process is still occupied, because PHP is already running. The ceiling stays the same, it just sits a little higher.
The actual ceiling: pm.max_children
pm.max_children is the maximum number of PHP worker processes an FPM pool runs at the same time. Each process handles exactly one request. The maximum throughput of a WordPress server in requests per second is therefore a division, not an opinion:
requests per second = pm.max_children / mean response time in seconds
The value of pm.max_children in turn follows from available memory, because every worker holds a WordPress installation in RAM. At 80 megabytes per process and 4 gigabytes of usable memory, 40 processes is the maximum before the server starts to swap. The table below works this through for typical setups.
| Memory available to PHP | pm.max_children at 80 MB per process | Mean response time | Maximum requests per second | Attack bandwidth at 600 bytes per request |
|---|---|---|---|---|
| 1 GB | 12 | 150 ms (cache hit inside PHP) | 80 | 0.38 Mbit/s |
| 2 GB | 25 | 250 ms (ordinary page) | 100 | 0.48 Mbit/s |
| 4 GB | 50 | 250 ms | 200 | 0.96 Mbit/s |
| 4 GB | 50 | 900 ms (search without cache) | 55 | 0.26 Mbit/s |
| 8 GB | 100 | 250 ms | 400 | 1.92 Mbit/s |
| 8 GB | 100 | 1,200 ms (WooCommerce checkout) | 83 | 0.40 Mbit/s |
| 16 GB | 200 | 250 ms | 800 | 3.84 Mbit/s |
The last column is the real message. Even a well equipped server with 16 gigabytes of memory is at the end of its PHP capacity at 3.84 Mbit/s of attack traffic. That is less than a single residential connection produces, and it is 0.38 percent of a 1 Gbit/s link. No traffic alert in the world fires at that level. How to calculate pm.max_children for your server instead of guessing is covered in Calculate PHP-FPM pm.max_children correctly.
A second effect makes the picture worse. Once every worker is busy, PHP-FPM queues further requests in the listen queue. Response time rises for everyone, including real visitors, and with response time the holding time per process rises as well. Throughput therefore drops exactly when it is needed. Turning pm.max_children up without adding memory does not buy throughput, it buys the OOM killer, which usually takes the MySQL process.
Why a plugin still makes sense
The point of this section is not that security plugins are useless. They solve a different task, and they solve it well: detecting known vulnerabilities in themes and plugins, checking for modified files, blocking individual malicious patterns, two-factor login, notification on sign-in. That is application security, and the application layer is the right place for it. A DDoS attack, by contrast, is a capacity problem, and capacity problems are always solved where the capacity ends. The fundamentals are covered in What is a DDoS attack.
What a Layer 7 attack on WordPress looks like
A Layer 7 attack is an attack at the application layer: it consists of entirely valid HTTP requests over fully established TCP connections. There is nothing suspicious in the packets themselves. What is suspicious is the volume and the choice of targets.
The typical run against WordPress has four traits. First, paths are chosen that cannot be answered from a cache, above all requests with changing parameters. Second, the response is often not read at all, the connection is closed right after sending, which shows up in the log as status code 499. Third, the requests come from a few hundred to a few tens of thousands of different addresses, frequently residential ones, because those cannot be blocked wholesale. Fourth, they carry plausible headers: a current browser identifier, matching Accept-Language values, sometimes even a valid referrer.
The difference from brute force against wp-login.php
Brute force against wp-login.php pursues a different goal and therefore behaves differently. The attacker wants in, not overload. That is why they deliberately stay below the threshold that triggers a block, and spread their attempts across thousands of sites at once. Wordfence reports blocking more than 6.4 billion such login attempts per month across its entire network. That is the background noise of the internet, not an attack aimed at you personally.
Technically you recognize brute force by a clear signature: POST /wp-login.php, status code 200 (WordPress answers a failed login attempt with 200 and an error message in the HTML, not with 401), an empty referrer or one pointing at the login page itself, and a rotation of either usernames or passwords while everything else stays the same.
The cost side is less clear-cut than often assumed. Since WordPress 6.8 (April 2025) passwords are verified with bcrypt instead of the older, MD5-based phpass scheme. bcrypt is deliberately compute-intensive, one verification costs several tens of milliseconds of CPU time at the default cost factor. A login attempt against an existing username has therefore become considerably more expensive. An attempt against a username that does not exist costs almost nothing, because WordPress aborts at the user lookup and never computes a hash. Brute force against the username admin is thus a load attack at the same time, brute force with dictionary names is not.
The difference from a genuine traffic surge
A real surge after a mention on a large portal, a newsletter or a viral post looks identical at first glance: requests rise, response time rises, the site gets slow. Six traits separate the two cases reliably.
| Trait | Layer 7 attack | Brute force on wp-login.php | Genuine traffic surge |
|---|---|---|---|
| Shape of the rise | full speed within seconds, then constant | steady and low for days | rise over minutes, decay over hours |
| Ratio of HTML to images and scripts | almost only HTML, hardly any static files | only the login page | 20 to 80 static files per page view |
| Referrer | empty or always the same value | empty or the login page itself | many different, genuine origin pages |
| Cache status in the log | consistently MISS or BYPASS | BYPASS (the login page is never cacheable) | mostly HIT after the first few seconds |
| Status codes | a notable share of 499, plus 502 and 504 | almost exclusively 200 | 200 and 304, hardly any 499 |
| Requested paths | few paths with many different parameters | exactly one path | broad spread across the real content |
The single most telling trait is the ratio of HTML requests to requests for static files. A real browser loads the stylesheets, fonts, scripts and images for every HTML page, usually 20 to 80 individual requests. An attack tool loads only the HTML, because only the HTML costs a PHP process. When that ratio flips from 1 to 40 down to 1 to 1 within a few seconds, you have your answer. The full measurement guide for the general case is in Detect a DDoS attack on your server.
The expensive endpoints of a WordPress installation
Not every WordPress URL costs the same. An attacker who knows the installation deliberately picks the paths that neither the page cache nor the object cache can serve. Those paths are the same on every WordPress installation, because they belong to the core.
Search: /?s=randomword
WordPress search is the most expensive endpoint of a default installation, and for two reasons at once. First, every search term creates a new cache key. An attacker who appends a different string to every request bypasses every page cache and every object cache without breaking a single rule. Second, WordPress implements search as a LIKE query with a leading wildcard, in essence:
SELECT wp_posts.ID FROM wp_posts
WHERE ((wp_posts.post_title LIKE '%searchterm%')
OR (wp_posts.post_excerpt LIKE '%searchterm%')
OR (wp_posts.post_content LIKE '%searchterm%'))
AND wp_posts.post_status = 'publish'
ORDER BY wp_posts.post_date DESC
A LIKE with a leading percent sign cannot use an index. MySQL reads the wp_posts table in full and compares three text columns in every row, one of them post_content, which on a grown site can run to several megabytes. On an installation with 5,000 posts such a query regularly takes 300 to 900 milliseconds. Ten concurrent search requests are enough to turn the database into the bottleneck, and the database then slows down every page that has nothing to do with search.
admin-ajax.php and the Heartbeat API
/wp-admin/admin-ajax.php is the central AJAX dispatcher of WordPress and a popular target for two reasons. It lives under /wp-admin/, yet it is reachable for visitors who are not logged in, because themes and plugins register their actions through wp_ajax_nopriv_*. And it is explicitly excluded from any caching through nocache_headers(), since its responses are meant to be current.
On top of that comes the Heartbeat API, which sends a request to admin-ajax.php every 15 seconds in the post editor and every 60 seconds on the remaining admin screens. It is therefore already the busiest dynamic path of an active editorial team in normal operation. An attacker does not need a valid action: a call without an action parameter, or with an unknown one, still boots WordPress in full and answers with a zero and status code 400. The worker process was occupied the whole time.
xmlrpc.php: amplifier and compute load in one
xmlrpc.php has been permanently active since WordPress 3.5, with no switch in the interface. Two methods make the file relevant.
pingback.ping instructs your server to fetch an arbitrary remote URL and look for a backlink in it. That turns your installation into a tool against third parties. In March 2014 Sucuri documented a Layer 7 attack driven through 162,000 clean, regularly operated WordPress sites: every one of them followed the specification and dutifully fetched the address it was given. For the operator of such a site that means outgoing traffic, occupied worker processes and, in the worst case, an entry on a block list.
system.multicall bundles any number of individual calls into one HTTP request. Combined with wp.getUsersBlogs that allows hundreds of password attempts inside a single POST. Any counter based on HTTP requests per address sees exactly one request. That is why block lists for wp-login.php alone are useless as long as xmlrpc.php is open.
A widespread misunderstanding belongs here: the xmlrpc_enabled filter only switches off the methods that require authentication. pingback.ping requires no authentication and keeps working unchanged afterwards. Anyone who really wants to close the pingback has to remove the method or shut the file at the web server level.
wp-cron.php: the scheduler anyone can trigger
WordPress does not ship a real scheduler. Instead it checks at the end of a page view whether a scheduled task is due, and for that it fires a loopback request to wp-cron.php. A simple lock through WP_CRON_LOCK_TIMEOUT keeps that from happening more than once every 60 seconds.
Two properties turn this into a target. The file is publicly reachable, anyone can call it. And every call boots WordPress in full, checks the task list and runs whatever is due, potentially including update checks, feed fetches and mail delivery. An attacker calling wp-cron.php in parallel creates a lot of work with very little effort and additionally provokes outgoing connections from your server.
WooCommerce: cart, checkout and fragments
A shop moves the limit considerably downward, because WooCommerce deliberately excludes large parts of the site from caching. Four points matter here.
?wc-ajax=get_refreshed_fragmentsis fetched on every single page while a cart cookie exists, so the mini cart stays current. That request is never cacheable and costs a full WordPress boot.- The session cookies
woocommerce_items_in_cartandwp_woocommerce_session_*switch off the page cache for that visitor. An attacker who simply sends those cookies along disables your cache for themselves, and nothing about it looks invalid. ?add-to-cart=123creates a session and therefore a write to the database. Writing requests are the most awkward kind, because they cannot be spread across read replicas.- Product filters with several parameters create a practically unlimited number of cache keys. Every combination is a fresh MISS.
The endpoints at a glance
| Path | What the server does for it | Servable from cache | Effective measure |
|---|---|---|---|
/?s=term |
full WordPress boot, table scan in MySQL | no, every term is a new key | dedicated rate limit, 1 to 2 requests per minute per address |
/wp-admin/admin-ajax.php |
full WordPress boot, even without a valid action | no, explicitly excluded | rate limit, allow front end actions by name |
/xmlrpc.php |
full boot, pingback fetches remote URLs | no | close it, or remove the pingback only |
/wp-cron.php |
full boot plus due tasks plus outgoing connections | no | disable it and run a real cron |
/wp-login.php |
full boot, one bcrypt verification for a known username | no | IP restriction or web server authentication in front |
/wp-json/wp/v2/... |
full boot, database queries per resource | rarely, mostly excluded | rate limit, block the user listing |
/?wc-ajax=get_refreshed_fragments |
full boot plus session access | no | rate limit, disable fragments in the theme |
/cart/, /checkout/, /my-account/ |
full boot, session, write operations | no, excluded by cookies | rate limit, cap concurrent connections |
/post-name/ without parameters |
on a cache hit, one file read by nginx | yes | full page cache, practically unlimited after that |
How to pin the attack down
Before you change anything you need three numbers: how many requests per second arrive, which paths they go to, and how many distinct addresses they come from. Everything else follows from those.
The PHP-FPM status page
The PHP-FPM status page is the most accurate instrument for this case, because it shows exactly the resource that runs out. It is enabled in the pool configuration, usually /etc/php/8.3/fpm/pool.d/www.conf:
pm.status_path = /fpm-status
request_slowlog_timeout = 5s
slowlog = /var/log/php8.3-fpm-slow.log
Plus an nginx block that is reachable locally only:
location = /fpm-status {
access_log off;
allow 127.0.0.1;
deny all;
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
systemctl reload php8.3-fpm nginx
watch -n 1 "curl -s http://127.0.0.1/fpm-status"
Four values decide. listen queue shows the requests currently waiting for a free worker. In normal operation it reads 0 permanently. Any sustained value above that means requests are not being handled slowly, they are not being handled at all. max listen queue is the peak since startup and stays there even after the attack is over. max children reached counts how often the pool hit its ceiling; a rising value is proof that pm.max_children is the limiting factor. slow requests counts requests beyond request_slowlog_timeout, and the matching file holds the full call stack, that is, the exact place where PHP is stuck.
With curl -s "http://127.0.0.1/fpm-status?full" you additionally see which URL each process is working on. When thirty processes show /index.php?s=... with different terms at the same time, the diagnosis is complete.
Reading the access log
First extend the log format, because the default format hides the two most important values: response time and cache status.
log_format kh '$remote_addr $status $request_time $upstream_response_time '
'$upstream_cache_status "$request" "$http_referer" "$http_user_agent"';
access_log /var/log/nginx/access.log kh;
The following evaluations need no extra tools. They assume the nginx default format combined, where field 1 is the address, field 4 the timestamp, field 7 the URL and field 9 the status code.
tail -n 200000 /var/log/nginx/access.log | awk '{print substr($4,2,20)}' | uniq -c | sort -rn | head -20
tail -n 200000 /var/log/nginx/access.log | awk '{print $1}' | sort | uniq -c | sort -rn | head -20
tail -n 200000 /var/log/nginx/access.log | awk '{print $1, substr($4,2,20)}' | sort | uniq -c | sort -rn | head -20
tail -n 200000 /var/log/nginx/access.log | awk '{print $7}' | sort | uniq -c | sort -rn | head -20
tail -n 200000 /var/log/nginx/access.log | awk '{print $9}' | sort | uniq -c | sort -rn
tail -n 200000 /var/log/nginx/access.log | awk '{print $1}' | sort -u | wc -l
The third line is the important one: it counts requests per address and second. A two-digit value there means a single flooder and you block it. Three thousand lines each showing 1 or 2 means a distributed attack, and any block list is the wrong road. The last line gives the number of participating addresses and therefore decides the entire strategy.
For the ratio of HTML to static files one line is enough:
tail -n 200000 /var/log/nginx/access.log \
| awk '{ if ($7 ~ /\.(css|js|png|jpg|jpeg|webp|svg|woff2?|ico)(\?|$)/) s++; else d++ } END { print "static:", s, " dynamic:", d }'
Reading status codes 499, 502 and 504 correctly
499 is not a standard code, it is an nginx peculiarity and means: the client closed the connection before nginx could answer. Two entirely different causes produce it. An attack tool that does not need the response and hangs up immediately, and a real visitor who gave up because the site was too slow. A high share of 499 on its own therefore proves nothing. Proof comes from the combination: many 499 together with a high $request_time and a great many distinct source addresses.
502 in this situation almost always means nginx could no longer connect to the FPM socket because its queue is full. The error log then typically reads connect() to unix:/run/php/php8.3-fpm.sock failed (11: Resource temporarily unavailable) while connecting to upstream. The remedy is not a larger listen.backlog, since a longer queue only lengthens the wait. The full list of causes is in Fix nginx 502 Bad Gateway.
504 means a worker accepted the request and took longer than fastcgi_read_timeout. During an attack on search that is the normal case, because the database is the bottleneck. Details are in Fix nginx 504 Gateway Timeout.
The nginx error log
Three lines in the error log are unambiguous for this attack pattern:
[error] 1123#1123: *8421 limiting requests, excess: 5.020 by zone "wp_expensive", client: 203.0.113.7, server: example.com, request: "GET /?s=qwertz HTTP/2.0"
[error] 1123#1123: *8422 connect() to unix:/run/php/php8.3-fpm.sock failed (11: Resource temporarily unavailable) while connecting to upstream
[alert] 1123#1123: 768 worker_connections are not enough
The first line is good news: the rate limit is working and it names the zone, the address and the requested URL. This is also the line the bundled fail2ban filter keys on. The second line means PHP-FPM is no longer accepting connections. The third means nginx itself is at its limit, which is rare in a pure Layer 7 attack on WordPress and usually points to a large number of connections held open.
What actually helps on the server
Everything below applies to Debian 12, Debian 13, Ubuntu 22.04 LTS and Ubuntu 24.04 LTS with nginx and PHP-FPM, and is written for root. The order is by effect, not by effort. Check every change with nginx -t before reloading.
1. A full page cache, so the flood hits files
By far the most effective single measure is a full page cache in the web server. A cache hit is answered by nginx from a file, without PHP being asked at all. That moves the ceiling from pm.max_children (between 12 and 200 in the table above) to worker_connections, which at four processes with 16,384 connections each sits at 65,536. The difference is three orders of magnitude.
First the zone and the rules for when nothing is cached. That belongs in the http block, so into /etc/nginx/conf.d/wp-cache.conf:
fastcgi_cache_path /var/cache/nginx/wp levels=1:2 keys_zone=WORDPRESS:100m inactive=60m max_size=2g;
fastcgi_cache_key "$scheme$request_method$host$request_uri";
map $http_cookie $kh_skip_cookie {
default 0;
"~*wordpress_logged_in_" 1;
"~*wp-postpass_" 1;
"~*comment_author_" 1;
"~*woocommerce_items_in_cart" 1;
"~*wp_woocommerce_session_" 1;
}
map $request_uri $kh_skip_uri {
default 0;
"~*^/wp-admin/" 1;
"~*^/wp-login\.php" 1;
"~*^/wp-json/" 1;
"~*^/(cart|checkout|my-account)/" 1;
"~*[?&](s|add-to-cart|wc-ajax)=" 1;
}
Then the PHP block in the server section:
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_cache WORDPRESS;
fastcgi_cache_valid 200 301 302 10m;
fastcgi_cache_valid 404 1m;
fastcgi_cache_bypass $kh_skip_cookie $kh_skip_uri;
fastcgi_no_cache $kh_skip_cookie $kh_skip_uri;
fastcgi_cache_lock on;
fastcgi_cache_lock_timeout 5s;
fastcgi_cache_use_stale updating error timeout invalid_header http_500 http_502 http_503 http_504;
fastcgi_cache_background_update on;
add_header X-Cache $upstream_cache_status always;
}
Four directives are the actual defense here, and they are exactly the ones missing from most guides.
fastcgi_cache_lock onmakes sure that with 500 simultaneous requests for the same page that is not yet cached, only a single one is passed to PHP. Without this line all 500 go through, and that rush on a cold cache is precisely what an attack triggers.fastcgi_cache_use_stale updatingtogether withfastcgi_cache_background_update onkeeps serving the old copy from disk to everyone else while an entry is being refreshed.fastcgi_cache_use_stale ... http_500 http_502 http_503 http_504keeps the site reachable even when PHP-FPM has already stopped answering. For a visitor, a page that is ten minutes old is still a page.add_header X-Cache $upstream_cache_status alwaysmakes the effect measurable. Without this line you do not know whether the cache is working.
One trap is worth knowing, because it sits in many configurations circulating online: fastcgi_cache_bypass $arg_nocache; or $http_pragma in the same line. Both can be set from outside. Anyone appending ?nocache=1 or sending the header Pragma: no-cache bypasses your entire cache, and does so entirely within the rules. The condition for a cache bypass must consist exclusively of values the visitor cannot choose freely.
Verify with two calls that it works:
mkdir -p /var/cache/nginx/wp && chown www-data:www-data /var/cache/nginx/wp
nginx -t && systemctl reload nginx
curl -sI https://example.com/ | grep -i x-cache
curl -sI https://example.com/ | grep -i x-cache
The first call reports MISS, the second HIT. If the second also reports MISS, one of your exclusions is firing, and the most common cause is a cookie a plugin sets on the very first call.
2. Rate limiting per source address with limit_req_zone
limit_req_zone limits requests per key using the leaky bucket method. The key is $binary_remote_addr, the shortened binary form of the source address. One state occupies 128 bytes on a 64-bit system, so a zone of 10 megabytes holds roughly 80,000 distinct addresses. For limit_conn it is 64 bytes per state and therefore roughly 160,000 addresses at 10 megabytes. Those figures come straight from the nginx documentation, and they are the reason a zone chosen too small is the first thing to overflow during a distributed attack.
limit_req_zone $binary_remote_addr zone=wp_general:20m rate=10r/s;
limit_req_zone $binary_remote_addr zone=wp_login:10m rate=20r/m;
limit_req_zone $kh_expensive_key zone=wp_expensive:20m rate=2r/m;
limit_conn_zone $binary_remote_addr zone=wp_conn:10m;
limit_req_status 429;
limit_conn_status 429;
limit_req_log_level warn;
The third zone uses a computed key, and that is the trick with which you limit only the expensive requests without touching the rest of the site. The nginx documentation states it plainly: requests with an empty key value are not accounted. That allows a zone which only engages when a particular parameter is present:
map $args $kh_expensive {
default 0;
"~*(^|&)s=" 1;
"~*(^|&)wc-ajax=" 1;
"~*(^|&)add-to-cart=" 1;
}
map $kh_expensive $kh_expensive_key {
0 "";
1 $binary_remote_addr;
}
An ordinary page request therefore gets the empty key and walks past the zone. A search request gets the address as its key and may pass twice per minute per address. This is applied in the server section:
location / {
limit_req zone=wp_general burst=20 nodelay;
limit_req zone=wp_expensive burst=3 nodelay;
limit_conn wp_conn 20;
try_files $uri $uri/ /index.php?$args;
}
location = /wp-login.php {
limit_req zone=wp_login burst=5 nodelay;
limit_conn wp_conn 5;
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
nodelay is set here on purpose. Without that switch nginx delays excess requests inside the burst window instead of rejecting them. Delaying means the connection stays open and keeps occupying an nginx connection. Against an attack that is the wrong direction. The default status is 503 by the way; 429 is the more precise answer and does not tell search engines the server is broken.
Do not pick the numbers from your gut. Measure for a week how many requests per second a single real address actually produces, and set the limit to three to five times that. Limits set too tightly throw out real visitors, and the most active ones go first.
3. The trap behind a service in front: the real address
When a reverse proxy, a CDN or a load balancer sits in front of your server, nginx does not see the visitor's address, it sees the proxy's. Any rate limit keyed on $binary_remote_addr then throttles the proxy and with it every visitor at once, while the attacker runs through unchecked. The correction goes through the realip module:
set_real_ip_from 203.0.113.0/24;
set_real_ip_from 198.51.100.0/24;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
What matters is that set_real_ip_from lists only networks you genuinely trust. If 0.0.0.0/0 ends up there by accident, any sender can set their own X-Forwarded-For header and bypass both the rate limit and every fail2ban block. You need the same change anyway so that WordPress and your logs see the correct address at all. How to build a clean upstream proxy is covered in Set up nginx as a reverse proxy.
4. Closing xmlrpc.php without breaking Jetpack
The cheapest option is to never hand the file to PHP at all:
location = /xmlrpc.php {
access_log off;
log_not_found off;
return 444;
}
return 444 is an nginx-specific special value: the connection is closed with no response whatsoever. That is the cheapest possible reaction and, unlike 403, produces no response body. Mind one nginx peculiarity here: return is evaluated in the rewrite phase and therefore ahead of allow and deny, which run in the access phase. Anyone writing both into the same block always gets the return. An exception for individual addresses therefore needs a case distinction through a map, not an additional allow.
If you use Jetpack, the WordPress app or a backup plugin that needs XML-RPC, remove only the pingback instead. That belongs in a small plugin under wp-content/mu-plugins/, not in the theme's functions.php, otherwise it is gone after the next theme change:
<?php
add_filter('xmlrpc_methods', function (array $methods): array {
unset(
$methods['pingback.ping'],
$methods['pingback.extensions.getPingbacks']
);
return $methods;
});
add_filter('xmlrpc_enabled', '__return_false');
The second filter on its own is explicitly not enough, because it only switches off the methods that require authentication. Only the combination closes both routes.
5. Disabling wp-cron and running a real schedule
Two steps that belong together. First, in wp-config.php, above the line stating that nothing below should be edited:
define('DISABLE_WP_CRON', true);
Then a real entry in the system scheduler that calls PHP directly and therefore needs neither the web server nor a network connection:
*/5 * * * * www-data /usr/bin/php8.3 /var/www/example.com/wp-cron.php >/dev/null 2>&1
And only now may the HTTP route be closed:
location = /wp-cron.php {
access_log off;
return 444;
}
The order matters. Anyone closing the HTTP route without setting up the real schedule first will within days have unpublished scheduled posts, missing backups and order confirmations that were never sent. After the change, verify once with wp cron event list --path=/var/www/example.com that the list is being worked off.
6. Keeping wp-login.php and wp-admin on a short leash
The strongest lever here, too, is to never start PHP in the first place. If you work from a fixed address, the matter is simple:
location = /wp-login.php {
allow 203.0.113.10;
deny all;
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
Without a fixed address, put web server authentication in front of it. A rejected request then costs one comparison in the web server instead of a full WordPress boot plus a bcrypt verification:
apt-get install -y apache2-utils
htpasswd -c /etc/nginx/.htpasswd editorial
location = /wp-login.php {
auth_basic "Editorial";
auth_basic_user_file /etc/nginx/.htpasswd;
limit_req zone=wp_login burst=5 nodelay;
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
location ^~ /wp-admin/ {
auth_basic "Editorial";
auth_basic_user_file /etc/nginx/.htpasswd;
location = /wp-admin/admin-ajax.php {
auth_basic off;
limit_req zone=wp_general burst=10 nodelay;
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
}
The nested block for admin-ajax.php is not a nicety, it is mandatory. Anyone locking /wp-admin/ completely also takes the AJAX dispatcher out of service, and after that contact forms, filters, carts and infinite scrolling stop working on the front end. In practice that troubleshooting costs more time than the entire rest of the setup.
7. fail2ban on the access log
fail2ban reads log files and installs blocks. It is therefore slower than an nginx rule, but it works for longer and at the packet level, that is, before nginx even accepts a connection. The bundled filter nginx-limit-req parses exactly the error log line shown above and only needs a jail in /etc/fail2ban/jail.d/wordpress.conf:
[nginx-limit-req]
enabled = true
port = http,https
logpath = /var/log/nginx/error.log
findtime = 300
maxretry = 20
bantime = 3600
banaction = nftables-multiport
For the expensive paths a dedicated filter working straight on the access log is worthwhile. Into /etc/fail2ban/filter.d/kh-wp-flood.conf:
[Definition]
failregex = ^<HOST> .* "(GET|POST|HEAD) /(\?s=|xmlrpc\.php|wp-cron\.php|wp-login\.php)
ignoreregex =
datepattern = \[%%d/%%b/%%Y:%%H:%%M:%%S %%z\]
[kh-wp-flood]
enabled = true
port = http,https
filter = kh-wp-flood
logpath = /var/log/nginx/access.log
findtime = 60
maxretry = 60
bantime = 7200
banaction = nftables-multiport
fail2ban-client reload
fail2ban-regex /var/log/nginx/access.log /etc/fail2ban/filter.d/kh-wp-flood.conf
fail2ban-client status kh-wp-flood
Two honest limitations belong here. First, banaction belongs on nftables-multiport or an ipset-based variant. The default of one iptables rule per ban produces a chain with 5,000 entries at 5,000 blocked addresses, traversed linearly for every incoming packet. The block list then becomes a load problem in its own right. Second, in a distributed attack from tens of thousands of addresses each sending only one or two requests per second, no threshold exists that spares real visitors. In that case fail2ban is the wrong tool, not a badly configured one. The basic setup with all jails is in Set up fail2ban, the matching rule set in front of it in Set up the UFW firewall.
8. Sizing PHP-FPM and the database correctly
A cleanly configured pool does not break later, but it breaks in a more controlled way. Three values matter:
pm = dynamic
pm.max_children = 50
pm.start_servers = 12
pm.min_spare_servers = 8
pm.max_spare_servers = 20
pm.max_requests = 500
request_terminate_timeout = 30s
listen.backlog = 511
request_terminate_timeout is the actual protective function here: it ends requests running longer than 30 seconds and releases the worker. Without it, an attack on search leaves processes stuck inside a MySQL query for minutes, and the pool never empties again. Set the value higher than your longest legitimate request, an import for instance, and lower than fastcgi_read_timeout in nginx.
The same thinking pays off on the database side. max_connections should match pm.max_children, otherwise you get an error page instead of a queue. An object cache on Redis lowers the cost per request considerably and therefore raises the ceiling. It does not help against the attack on /?s=randomword, however, because every term is a new key. Worse, the useless entries evict the useful ones, and after the attack the cache is empty. Set maxmemory-policy allkeys-lru and give the object cache its own Redis database, so that sessions and carts are not evicted along with it.
Where these measures end
Everything described so far runs on your server, that is, at the end of the line. An nginx rule decides about a packet that has already crossed the cable. You can discard it, but you cannot un-send it. If the line in front is full, or the packet rate is higher than the network card and the kernel can process, your visitors' packets no longer arrive at all.
| Link | Payload per second | Packets per second at 64 bytes | HTTP requests per second at 600 bytes |
|---|---|---|---|
| 100 Mbit/s | 12.5 megabytes | 148,809 | 20,833 |
| 1 Gbit/s | 125 megabytes | 1,488,095 | 208,333 |
| 10 Gbit/s | 1,250 megabytes | 14,880,952 | 2,083,333 |
Compare the last column with the table from the first section. A 1 Gbit/s link carries 208,333 HTTP requests per second on paper, while a WordPress server handles between 80 and 800. The gap is a factor of 260 to 2,600. For a Layer 7 attack the line is therefore never the limit, and that is exactly why the traffic graph stays unremarkable.
From this follows a clean separation: if your line fills up, you no longer have a Layer 7 attack, you have a volumetric one. From that point on not a single setting on your server matters, not even the best cache, because the request never reaches the server. The same goes for the packet rate: an ordinary server kernel processes a few hundred thousand packets per second before it starts discarding, and then it discards without regard to the sender. What to do in that case is covered in What to do during a severe DDoS attack.
A third limit is rarely mentioned and hits HTTPS sites in particular. Every new TLS session costs an asymmetric operation. An attack that keeps opening new connections instead of reusing an existing one therefore creates CPU load before a single HTTP request has been read. A shared session cache softens that, because a resumed session skips the expensive part:
ssl_session_cache shared:SSL:20m;
ssl_session_timeout 1h;
ssl_session_tickets on;
One megabyte of that cache holds roughly 4,000 sessions, so 20 megabytes holds about 80,000. It does not help against an attacker who deliberately renegotiates every time, but it very much helps your real visitors under load. How to get the certificates for that free of charge and automatically renewed is covered in Free SSL certificate with Let's Encrypt.
WAF and CDN: what they deliver and where the gap sits
A service in front such as Cloudflare or Sucuri works on a different principle from everything described so far, and that principle works. The service accepts the visitor's connection itself, terminates TLS there, inspects the request and only then opens its own connection to your server. A request rejected there never reaches your server and costs you no worker process. For Layer 7 attacks on HTTP and HTTPS that is a clean solution, and it brings caching at many locations along with it.
The gap is just as clear: all of it only works as long as the attack goes through the service. Your server still has its own, publicly reachable IP address. If the attacker knows it, they send their traffic straight there and the entire upstream arrangement has no effect. Keeping that address secret is considerably harder than it sounds.
The ways your origin IP becomes known
- DNS history. Several services archive DNS records for years. The A record that pointed straight at your server before the switch to the proxy is still there. A switch deletes no history.
- Mail headers from your own server. Every message WordPress sends carries the address of the sending system in its
Receivedlines. That covers the confirmation from a contact form, the password reset message, a shop's order confirmation and every notification about a new comment. An attacker only has to fill in a form and read the headers. - Outgoing connections from your installation. WordPress calls remote addresses on its own: update checks, embedded previews through oEmbed, webhooks, and with pingback even any URL somebody hands it. Anyone who slips your server an address they control simply reads the source address from their own log afterwards. That is the second, practical reason to remove
pingback.pingand to keepwp-cron.phpoff HTTP. - Certificate Transparency logs. Every publicly trusted certificate is written into public, append-only logs (the mechanism is described in RFC 6962). Searching them for your domain returns every certificate ever issued along with all names contained in it, test systems and internal designations included. If your origin server serves the same certificate on port 443, it identifies itself as soon as somebody scans the candidate address ranges.
- Unprotected subdomains.
mail.,webmail.,cpanel.,ftp.,dev.,staging.anddirect.point at the same machine in many setups and do not run through the proxy. An MX record fundamentally cannot run through an HTTP proxy, so it always names a real address.
Add to that small items with the same effect: a forgotten phpinfo.php, an error page printing the server name, an old backup archive inside the web directory, or a response header naming the internal address.
Closing the origin IP, and what stays open after that
If you use a service in front, your server should only be reachable from that service. At the packet level with nftables:
table inet filter {
set proxy_v4 {
type ipv4_addr
flags interval
elements = { 203.0.113.0/24, 198.51.100.0/24 }
}
chain input {
type filter hook input priority filter; policy drop;
iif lo accept
ct state established,related accept
ct state invalid counter drop
tcp dport { 80, 443 } ip saddr @proxy_v4 accept
tcp dport 22 ip saddr 203.0.113.10 accept
counter drop
}
}
List only the networks published by the service under proxy_v4, plus your own address for SSH, before loading the rule set, otherwise you lock yourself out. The complete rule set for day to day operation, including SYN cookies and adjusted conntrack limits, is in How to protect your server from DDoS attacks.
In addition, a default server block in nginx makes sure a direct connection with an unknown name gets no answer at all:
server {
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
ssl_reject_handshake on;
}
Now the honest part, which hardly any guide states: this closes the HTTP route, not the network route. The attacker's packets still reach your line and your network card, they are simply no longer answered. For a Layer 7 attack that is enough, because its volume is small. For a volumetric attack on the same address it changes nothing at all: the line is full before your rule ever runs. Filtering therefore has to sit in front of the line, not behind it, and only the operator of the network can provide that.
Which layer solves which attack
| Type of attack | Measures on the server | Service in front | Filtering in the network ahead |
|---|---|---|---|
HTTP flood on /?s= at 500 requests per second |
cache and rate limiting solve it completely | solves it completely while traffic runs through it | sees valid connections, engages only on conspicuous patterns |
Brute force on wp-login.php |
authentication in front plus fail2ban solve it | solves it through its own rules | not the right layer |
| Connection flood, tens of thousands of open TCP sessions | limit_conn and short timeouts soften it, do not solve it |
solves it, because the connection ends there | solves it at the packet level |
| SYN flood with spoofed senders | SYN cookies keep the service alive | protects only the service's address, not yours | solves it completely |
| UDP flood or amplification attack at 100 Gbit/s | no effect, the line fills up first | no effect when the origin IP is hit directly | the only layer that solves it |
| Packet rate beyond one million per second | no effect, the kernel discards blindly | no effect under direct fire | the only layer that solves it |
The table shows why the question "plugin or CDN or server filter" is posed wrongly. The three layers solve different attacks, and in the first two rows the server configuration is in fact the most effective and the cheapest answer. In the last three rows there is exactly one column that reads anything other than "no effect".
What KernelHost puts up against it
The always-on protection included in every server package
DDoS protection at KernelHost is built in two stages and permanently active, with no order, no switch and no surcharge:
- Stage 1: 17 Tbps of mitigation capacity in the global scrubbing network. Volumetric attacks are cleaned close to their source before they reach the data center.
- Stage 2: Arbor real-time filtering with 3.2 Tbps in Frankfurt am Main. Directly in front of the server, protocol-specific patterns are recognized and discarded, packet by packet.
Two properties are decisive for a WordPress operator. Protection is active from the moment the server is provisioned, so there are no minutes at the start of an attack during which the site is gone. And no null routing is used: your IP address stays reachable on the network, only harmful packets are discarded. That is the difference that decides whether an attack means five minutes of disruption or four hours of downtime.
Advanced DDoS Protection for projects under constant fire
Some projects are not hit occasionally but deliberately and over weeks, often at the same time of day and with changing patterns. For those there is Advanced DDoS Protection from €50.00 per month, prepaid, with no minimum term and no setup fee. The difference lies not in more capacity but in control:
- A dedicated protected IP from the Frankfurt core, onto which your server is switched inside our own network. Nothing has to be rebuilt on your side.
- Self-managed protection rules per port and protocol in the customer area. For a WordPress site that means separate rules for 80 and 443 versus everything else, such as SSH, database or mail.
- Changes take effect in real time. You can adjust during a running attack instead of waiting for a maintenance window.
- Dedicated profiles for HTTP, HTTPS, TLS and WordPress as well as for any service of your own on free TCP and UDP ports.
Advanced DDoS Protection is aimed at KernelHost customers and requires a server at KernelHost. If your project currently runs elsewhere and is regularly under fire there, moving it here is the way, not remote management of your existing address. How a WordPress migration runs without downtime, with a lowered TTL, a delta sync and a test through the hosts file, is described step by step in Migrate WordPress to a new server.
The two stages compared
| Feature | Included always-on DDoS protection | Advanced DDoS Protection |
|---|---|---|
| Price | included in every server package, no surcharge | from €50.00 per month, prepaid |
| Filtering capacity | 17 Tbps global scrubbing plus Arbor real-time filtering with 3.2 Tbps in Frankfurt am Main | the same two-stage filtering |
| Active from | provisioning of the server | provisioning of the protected IP |
| IP address | the IP address of your server | an additional dedicated protected IP |
| Rule set | automatic profiles, no configuration needed | your own rules per port and protocol in the customer area |
| Changes | applied automatically | take effect in real time, even during an attack |
| Null routing | no | no |
| Term | tied to the server package | prepaid, no minimum term, no notice period, no setup fee |
And the distinction that belongs in this article: filtering in the network works on packets, connections and protocol patterns. It cannot know that /?s=randomword is more expensive on your installation than /imprint/, because both are valid HTTPS requests inside an encrypted connection. The division of labor is therefore clear: volume, packet rate, connection floods and protocol anomalies are handled by the filtering in front, while the cost per individual valid request is handled by the full page cache and the rate limit on your server. Together they make a defense, each on its own has an open flank.
What to do during a running attack, in this order
The order matters more than the individual steps, because the most expensive mistakes happen in the first five minutes.
- Measure, do not turn knobs. First save the output of
curl -s "http://127.0.0.1/fpm-status?full", the last 200,000 lines of the access log and the last 200 lines of the error log into files. After the attack those values are gone. - Do not reboot. A reboot wipes every counter and every piece of evidence, and the load is back within seconds.
- Determine the layer. If the line is full or the packet rate is high, it is not a Layer 7 attack and nothing on the server will help. If the line is empty and
listen queueis high, you are in the right place. - Determine the number of addresses. A handful of addresses you block immediately. Ten thousand addresses you do not block, you rate limit.
- Switch on or enforce the full page cache. This is the measure with the largest leverage per minute of effort. Verify afterwards with
X-Cachethat it is working. - Turn down the expensive paths. Rate limit
/?s=,admin-ajax.phpandwc-ajax, setxmlrpc.phpandwp-cron.phptoreturn 444. These four changes take effect immediately afternginx -s reloadand need no PHP restart. - Close the login. Web server authentication in front of
wp-login.phpand/wp-admin/, with the exception foradmin-ajax.php. - Only now touch PHP-FPM. Set
request_terminate_timeoutso stuck processes are released. Raisepm.max_childrenonly if free memory genuinely allows it. - Do not change your address in haste. A change only works if all five leaks from the origin IP section are closed at the same time. A single stale DNS record makes it pointless.
- Bring your provider in with numbers. A ticket stating the time, the target port, requests per second, the number of source addresses and the three most requested paths is handled orders of magnitude faster than a report that the site is slow.
- Document it afterwards. Start, duration, pattern, target paths. Recurring attacks at the same time of day are the criterion for whether a project needs a dedicated protected IP.
Common mistakes and what helps instead
- Doubling
pm.max_childrenbecause the pool is full. Without additional memory that ends with the OOM killer, which usually takes MySQL. After that the site is not slow, it is broken. Calculate the value from free memory, not from wishful thinking. - Installing a security plugin during the attack. The installation itself needs a worker process that is not available right now, and afterwards the plugin inspects every request inside PHP. The measure only takes effect after the attack.
- Making the cache bypassable through a parameter.
fastcgi_cache_bypass $arg_nocacheor$http_pragmais an open door. The condition may only consist of values the visitor cannot set. - Rate limiting behind a proxy without
set_real_ip_from. You then throttle the proxy and with it all real visitors, while the attacker runs through. - Running a caching plugin and
fastcgi_cacheat once without aligning them. The result is contradictory headers and a cache that is not purged after a post is edited. Decide on one instance that owns the cache. - Locking
/wp-admin/completely. Without the exception foradmin-ajax.php, contact forms, filters and carts break on the front end, usually unnoticed. - Blocking
wp-cron.phpwithout setting up a real schedule. Scheduled posts, backups and order confirmations then sit there silently. - Maintaining block lists by hand. In a distributed attack no list grows fast enough, and every entry costs additional memory and lookup time on an already overloaded system.
- Changing the IP address and leaving the leaks open. DNS history, mail headers and transparency logs lead the attacker to the new address within minutes.
In short
- A WordPress plugin sees a request only after it has passed the web server, occupied a PHP-FPM worker and booted WordPress. A rejected request therefore costs exactly as much as a page that was served.
- The hard ceiling is called
pm.max_children. Maximum throughput ispm.max_childrendivided by the mean response time, which comes out between 80 and 800 requests per second depending on the server. - 500 requests per second at 600 bytes each is 2.4 Mbit/s. A 1 Gbit/s link carries 208,333 HTTP requests per second on paper. That is why the traffic graph stays unremarkable during a Layer 7 attack.
- The most expensive endpoints are always the same:
/?s=(not cacheable, table scan in MySQL),admin-ajax.php,xmlrpc.php,wp-cron.php, the REST API and, with WooCommerce, cart, checkout and fragments. - Measurement happens on four values of the PHP-FPM status page (
listen queue,max listen queue,max children reached,slow requests) and on requests per address and second in the access log. Status code 499 alone proves nothing, 499 together with high response time and many source addresses does. - What works on the server, in this order: a full page cache with
fastcgi_cache_lockandfastcgi_cache_use_stale,limit_req_zonewith its own zone for expensive parameters,limit_conn_zone,xmlrpc.phpandwp-cron.phpset toreturn 444, authentication in front of the login, fail2ban with nftables blocks. - Cloudflare, Sucuri and Wordfence are legitimate solutions with a different approach. The services in front work while traffic runs through them; they do not help when the attack goes straight to the origin IP. That address regularly becomes known through DNS history, mail headers, outgoing connections, Certificate Transparency logs and unprotected subdomains.
- Above the line and packet rate limit, only filtering in the network in front of the server decides the outcome. At KernelHost it is two-stage, included in every server package at no surcharge and active from provisioning, with no null routing: 17 Tbps of mitigation capacity in the global scrubbing network plus Arbor real-time filtering with 3.2 Tbps in Frankfurt am Main.
- Advanced DDoS Protection adds a dedicated protected IP and self-managed rules per port and protocol from €50.00 per month, prepaid, with no minimum term and no setup fee.
If your WordPress already runs at KernelHost, the filtering is active without you doing anything. The full page cache and the rate limits from this article remain your job, because they cover a different class of attack. If you notice anything unusual, open a support ticket with the five details from step 10. During a running attack you can also reach us through the WhatsApp emergency chat at +43 650 8209883.
Frequently asked questions
Does a WordPress plugin protect against DDoS attacks?
Why does my site go offline while the traffic graph looks normal?
How many requests per second can a WordPress server handle?
Which WordPress URLs are the most expensive during an attack?
How do I recognize a Layer 7 attack on WordPress?
What does status code 499 mean in the nginx log?
Should I block xmlrpc.php?
Does a full page cache help against a DDoS attack?
Is Cloudflare or Sucuri enough as DDoS protection for WordPress?
How does an attacker find the origin IP behind a CDN?
How do I switch wp-cron over correctly?
What does KernelHost put up against attacks on WordPress sites?
2026 KernelHost GmbH. All rights reserved. This guide is protected by copyright. Republishing it on other websites, in whole, in part or in edited form, is not permitted without our written consent. Quoting with a source credit and a link is expressly welcome.

