{"id":1585,"date":"2025-11-09T19:16:41","date_gmt":"2025-11-09T16:16:41","guid":{"rendered":"https:\/\/www.dchost.com\/blog\/the-calm-way-to-stop-wp-login-php-and-xml%e2%80%91rpc-brute-force-nginx-rate-limiting-fail2ban\/"},"modified":"2025-11-09T19:16:41","modified_gmt":"2025-11-09T16:16:41","slug":"the-calm-way-to-stop-wp-login-php-and-xml%e2%80%91rpc-brute-force-nginx-rate-limiting-fail2ban","status":"publish","type":"post","link":"https:\/\/www.dchost.com\/blog\/en\/the-calm-way-to-stop-wp-login-php-and-xml%e2%80%91rpc-brute-force-nginx-rate-limiting-fail2ban\/","title":{"rendered":"The Calm Way to Stop wp-login.php and XML\u2011RPC Brute Force: Nginx Rate Limiting + Fail2ban"},"content":{"rendered":"<div class=\"dchost-blog-content-wrapper\"><p>So there I was, coffee in hand, reading through yet another noisy access log. Lines and lines of <strong>POST \/wp-login.php<\/strong> requests, all from random IPs that had never bought a thing, never read a blog post, never even loaded the homepage. If you\u2019ve ever managed a WordPress site for more than a week, I bet you\u2019ve seen this too. Ever had that moment when your CPU spikes for no good reason, or your admin panel feels oddly sluggish despite low traffic? Nine times out of ten, it\u2019s the constant drumbeat of bots pecking at <strong>wp-login.php<\/strong> and <strong>xmlrpc.php<\/strong>.<\/p>\n<p>Here\u2019s the thing: you don\u2019t need to play whack\u2011a\u2011mole with plugins or hold your breath hoping the storm passes. You can get ahead of it\u2014calmly and predictably\u2014with a simple, layered approach I\u2019ve used across client sites and my own projects. In this friendly guide, we\u2019ll walk through a practical setup using <strong>Nginx rate limiting<\/strong> to slow the bots down and <strong>Fail2ban<\/strong> to show chronic offenders the door. We\u2019ll talk about what\u2019s actually happening, how to tune the limits so your real users don\u2019t feel punished, how to avoid common pitfalls (hello, Cloudflare), and how to keep your logs clean so you can sleep at night.<\/p>\n<p>By the end, you\u2019ll have a working recipe: a gate that politely slows excessive requests and a bouncer that bans the troublemakers\u2014no drama needed.<\/p>\n<div id=\"toc_container\" class=\"toc_transparent no_bullets\"><p class=\"toc_title\">\u0130&ccedil;indekiler<\/p><ul class=\"toc_list\"><li><a href=\"#Why_wp-loginphp_and_XML-RPC_get_hammered_and_what_it_feels_like\"><span class=\"toc_number toc_depth_1\">1<\/span> Why wp-login.php and XML-RPC get hammered (and what it feels like)<\/a><\/li><li><a href=\"#The_simple_layered_plan_slow_at_the_gate_ban_at_the_door\"><span class=\"toc_number toc_depth_1\">2<\/span> The simple, layered plan: slow at the gate, ban at the door<\/a><\/li><li><a href=\"#Preparing_Nginx_real_IPs_clean_logs_and_a_calm_playground\"><span class=\"toc_number toc_depth_1\">3<\/span> Preparing Nginx: real IPs, clean logs, and a calm playground<\/a><ul><li><a href=\"#Step_1_Make_sure_Nginx_sees_the_visitors_real_IP\"><span class=\"toc_number toc_depth_2\">3.1<\/span> Step 1: Make sure Nginx sees the visitor\u2019s real IP<\/a><\/li><li><a href=\"#Step_2_Add_a_log_format_thats_easy_for_Fail2ban\"><span class=\"toc_number toc_depth_2\">3.2<\/span> Step 2: Add a log format that\u2019s easy for Fail2ban<\/a><\/li><li><a href=\"#Step_3_Define_whitelistallowlist_for_your_own_IPs_optional_but_nice\"><span class=\"toc_number toc_depth_2\">3.3<\/span> Step 3: Define whitelist\/allowlist for your own IPs (optional but nice)<\/a><\/li><\/ul><\/li><li><a href=\"#Nginx_rate_limiting_firm_friendly_and_focused\"><span class=\"toc_number toc_depth_1\">4<\/span> Nginx rate limiting: firm, friendly, and focused<\/a><ul><li><a href=\"#Step_4_Define_zones_and_rates\"><span class=\"toc_number toc_depth_2\">4.1<\/span> Step 4: Define zones and rates<\/a><\/li><li><a href=\"#Step_5_Apply_limits_to_the_right_places\"><span class=\"toc_number toc_depth_2\">4.2<\/span> Step 5: Apply limits to the right places<\/a><\/li><li><a href=\"#Why_nodelay\"><span class=\"toc_number toc_depth_2\">4.3<\/span> Why nodelay?<\/a><\/li><\/ul><\/li><li><a href=\"#Fail2ban_the_patient_bouncer_who_remembers_faces\"><span class=\"toc_number toc_depth_1\">5<\/span> Fail2ban: the patient bouncer who remembers faces<\/a><ul><li><a href=\"#Step_6_Install_Fail2ban_and_set_the_basics\"><span class=\"toc_number toc_depth_2\">5.1<\/span> Step 6: Install Fail2ban and set the basics<\/a><\/li><li><a href=\"#Step_7_Create_dedicated_jails_for_wp-loginphp_and_xmlrpcphp\"><span class=\"toc_number toc_depth_2\">5.2<\/span> Step 7: Create dedicated jails for wp-login.php and xmlrpc.php<\/a><\/li><li><a href=\"#Step_8_Add_filters_that_recognize_abuse_by_endpoint_and_status\"><span class=\"toc_number toc_depth_2\">5.3<\/span> Step 8: Add filters that recognize abuse by endpoint and status<\/a><\/li><li><a href=\"#Step_9_Optionalrecidivists_and_longer_bans\"><span class=\"toc_number toc_depth_2\">5.4<\/span> Step 9: Optional\u2014recidivists and longer bans<\/a><\/li><\/ul><\/li><li><a href=\"#XML-RPC_realities_when_to_block_when_to_throttle_and_how_to_be_kind\"><span class=\"toc_number toc_depth_1\">6<\/span> XML-RPC realities: when to block, when to throttle, and how to be kind<\/a><\/li><li><a href=\"#Tuning_without_hurting_real_users\"><span class=\"toc_number toc_depth_1\">7<\/span> Tuning without hurting real users<\/a><\/li><li><a href=\"#Testing_the_setup_like_a_friendly_attacker\"><span class=\"toc_number toc_depth_1\">8<\/span> Testing the setup like a friendly attacker<\/a><\/li><li><a href=\"#Monitoring_and_clean_logs_keep_it_visible_and_quiet\"><span class=\"toc_number toc_depth_1\">9<\/span> Monitoring and clean logs: keep it visible and quiet<\/a><\/li><li><a href=\"#Edge_cases_CDNs_load_balancers_and_multiple_app_servers\"><span class=\"toc_number toc_depth_1\">10<\/span> Edge cases: CDNs, load balancers, and multiple app servers<\/a><\/li><li><a href=\"#A_friendly_checklist_to_wrap_it_up\"><span class=\"toc_number toc_depth_1\">11<\/span> A friendly checklist to wrap it up<\/a><\/li><li><a href=\"#Common_tweaks_and_niceties_Ive_learned_over_time\"><span class=\"toc_number toc_depth_1\">12<\/span> Common tweaks and niceties I\u2019ve learned over time<\/a><\/li><li><a href=\"#A_quick_word_on_documentation_and_going_deeper\"><span class=\"toc_number toc_depth_1\">13<\/span> A quick word on documentation and going deeper<\/a><\/li><li><a href=\"#Wrapup_a_calmer_WordPress_without_the_drama\"><span class=\"toc_number toc_depth_1\">14<\/span> Wrap\u2011up: a calmer WordPress, without the drama<\/a><\/li><\/ul><\/div>\n<h2 id=\"section-1\"><span id=\"Why_wp-loginphp_and_XML-RPC_get_hammered_and_what_it_feels_like\">Why wp-login.php and XML-RPC get hammered (and what it feels like)<\/span><\/h2>\n<p>I remember a client who swore they were being \u201cDDoS\u2019d\u201d because their WordPress admin was sluggish every afternoon. Turned out it wasn\u2019t a volumetric attack\u2014it was a slow, persistent <em>drip<\/em> of login attempts and XML-RPC probes from hundreds of IPs. Not huge traffic, but just enough noise to keep PHP busy, disks chattering, and everyone grumpy. That\u2019s the sneaky part: these bots don\u2019t always try to break your door; sometimes they just lean on it all day, hoping a hinge gives.<\/p>\n<p>Two targets steal the show. First, <strong>wp-login.php<\/strong>, the classic login endpoint. Bots try common passwords and leaked email\/user combos. It\u2019s credential stuffing more than guesswork. Second, <strong>xmlrpc.php<\/strong>, which allows remote procedure calls. It\u2019s useful for some tooling (Jetpack, the WordPress mobile app), but it\u2019s also been abused for brute force via <em>system.multicall<\/em>, letting attackers attempt many logins in a single request. Even if your credentials are strong, the resource cost of handling those requests adds up.<\/p>\n<p>What you\u2019ll notice: odd CPU rises that don\u2019t map to legitimate traffic, admin login feels sticky, and logs full of repetitive POSTs. Sometimes WooCommerce checkout slows down for no obvious reason. The app isn\u2019t \u201cbroken\u201d\u2014it\u2019s just busy serving freeloaders.<\/p>\n<h2 id=\"section-2\"><span id=\"The_simple_layered_plan_slow_at_the_gate_ban_at_the_door\">The simple, layered plan: slow at the gate, ban at the door<\/span><\/h2>\n<p>In my experience, the cleanest defense uses two layers that complement each other. Think of <strong>Nginx rate limiting<\/strong> as the gentle gate that lets normal behavior through and slows pushy requests. It doesn\u2019t get angry; it just says, \u201cWhoa there, one at a time.\u201d Then <strong>Fail2ban<\/strong> watches for persistent misbehavior (like getting hit with 429 Too Many Requests or 403 Forbidden repeatedly) and adds those IPs to a ban list. That\u2019s your bouncer: firm, fair, and not emotional.<\/p>\n<p>Why both? Rate limiting alone reduces load but persistent bad actors keep leaning. Fail2ban alone is reactive and can miss bursts that overwhelm PHP before the ban kicks in. Together, they\u2019re fantastic. Nginx cuts the majority of the noise cheaply at the edge; Fail2ban removes the repeat offenders from the equation entirely.<\/p>\n<p>We\u2019ll build this in steps: make sure Nginx sees the real client IP (super important behind a CDN), add a sensible log format, apply targeted rate limits to wp-login.php and xmlrpc.php, and then teach Fail2ban to ban IPs that keep misbehaving. Along the way, we\u2019ll talk about whitelisting your office IP, avoiding false positives, and what to do if you need XML-RPC for specific services.<\/p>\n<h2 id=\"section-3\"><span id=\"Preparing_Nginx_real_IPs_clean_logs_and_a_calm_playground\">Preparing Nginx: real IPs, clean logs, and a calm playground<\/span><\/h2>\n<h3><span id=\"Step_1_Make_sure_Nginx_sees_the_visitors_real_IP\">Step 1: Make sure Nginx sees the visitor\u2019s real IP<\/span><\/h3>\n<p>If you\u2019re behind a CDN or reverse proxy (Cloudflare, a load balancer, another Nginx), Nginx might only see the proxy\u2019s IP. If you rate limit or ban that, you\u2019ll block everyone. That\u2019s\u2026 not ideal.<\/p>\n<p>Set the real IP headers before anything else. For Cloudflare, for example, you\u2019d do:<\/p>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\">http {\n    # If you're on Cloudflare\n    set_real_ip_from 173.245.48.0\/20;\n    set_real_ip_from 103.21.244.0\/22;\n    set_real_ip_from 103.22.200.0\/22;\n    set_real_ip_from 103.31.4.0\/22;\n    set_real_ip_from 141.101.64.0\/18;\n    set_real_ip_from 108.162.192.0\/18;\n    set_real_ip_from 190.93.240.0\/20;\n    set_real_ip_from 188.114.96.0\/20;\n    set_real_ip_from 197.234.240.0\/22;\n    set_real_ip_from 198.41.128.0\/17;\n    set_real_ip_from 162.158.0.0\/15;\n    set_real_ip_from 104.16.0.0\/13;\n    set_real_ip_from 104.24.0.0\/14;\n    set_real_ip_from 172.64.0.0\/13;\n    set_real_ip_from 131.0.72.0\/22;\n\n    real_ip_header CF-Connecting-IP;\n    real_ip_recursive on;\n}\n<\/code><\/pre>\n<p>Cloudflare updates these ranges periodically, so keep them fresh or use their published list. If you\u2019re on a different proxy, use its equivalent. This matters for both rate limiting and Fail2ban.<\/p>\n<p>If you\u2019re curious about the correct header and setup for Cloudflare, their <a href=\"https:\/\/developers.cloudflare.com\/support\/troubleshooting\/restoring-visitor-ips\/restoring-original-visitor-ips\/\" rel=\"nofollow noopener\" target=\"_blank\">restoring original visitor IPs guide<\/a> is a handy reference.<\/p>\n<h3><span id=\"Step_2_Add_a_log_format_thats_easy_for_Fail2ban\">Step 2: Add a log format that\u2019s easy for Fail2ban<\/span><\/h3>\n<p>I like a straightforward log line that includes the status and request line. Here\u2019s a minimal example you can drop into the http block:<\/p>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\">http {\n    log_format main_ext '$remote_addr - $remote_user [$time_local] '\n                        '&quot;$request&quot; $status $body_bytes_sent '\n                        '&quot;$http_referer&quot; &quot;$http_user_agent&quot; '\n                        'rt=$request_time';\n\n    access_log \/var\/log\/nginx\/access.log main_ext;\n}\n<\/code><\/pre>\n<p>We\u2019ll lean on this later when Fail2ban looks for repeated 429s or 403s for the login endpoints.<\/p>\n<h3><span id=\"Step_3_Define_whitelistallowlist_for_your_own_IPs_optional_but_nice\">Step 3: Define whitelist\/allowlist for your own IPs (optional but nice)<\/span><\/h3>\n<p>You don\u2019t have to do this, but it\u2019s humane. If there\u2019s a static office IP or a secure VPN range, allow it to skip limits. The <em>map<\/em> directive makes this pleasant:<\/p>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\">http {\n    map $remote_addr $wp_skip_limit {\n        default 0;\n        203.0.113.10 1;     # Example: Office IP\n        2001:db8::\/48 1;     # Example: Office IPv6 prefix\n    }\n}\n<\/code><\/pre>\n<p>We\u2019ll use $wp_skip_limit inside the locations to skip rate limiting when it equals 1.<\/p>\n<h2 id=\"section-4\"><span id=\"Nginx_rate_limiting_firm_friendly_and_focused\">Nginx rate limiting: firm, friendly, and focused<\/span><\/h2>\n<p>You don\u2019t need to rate limit your entire site\u2014just the choke points. The idea is to target <strong>POST \/wp-login.php<\/strong> and <strong>POST \/xmlrpc.php<\/strong> and keep the limits humane. For wp-login.php, you want it tight. For xmlrpc.php, you want it tighter, especially against system.multicall.<\/p>\n<h3><span id=\"Step_4_Define_zones_and_rates\">Step 4: Define zones and rates<\/span><\/h3>\n<p>In the http block, add two zones. Memory size roughly maps to how many distinct IPs you expect to track; 10m is plenty for most sites.<\/p>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\">http {\n    # ...real IP, log_format, map from above...\n\n    # One token bucket per client IP for login and XML-RPC\n    limit_req_zone $binary_remote_addr zone=login_zone:10m rate=3r\/m;\n    limit_req_zone $binary_remote_addr zone=xmlrpc_zone:10m rate=10r\/m;\n}\n<\/code><\/pre>\n<p>Translation: allow around 3 login attempts per minute per IP, and about 10 XML-RPC requests per minute. Adjust later if you have special tooling. The point is to keep the pressure off PHP.<\/p>\n<h3><span id=\"Step_5_Apply_limits_to_the_right_places\">Step 5: Apply limits to the right places<\/span><\/h3>\n<p>Inside your server block for the site:<\/p>\n<pre class=\"language-nginx line-numbers\"><code class=\"language-nginx\">server {\n    # ... SSL, server_name, root, etc. ...\n\n    # 429s for limits, don\u2019t burst too high\n    limit_req_status 429;\n\n    location = \/wp-login.php {\n        # Skip if allowlisted\n        if ($wp_skip_limit) {\n            set $limit_bypass 1;\n        }\n\n        # Only limit POST requests; GET for rendering login page is less risky\n        if ($request_method = POST) {\n            set $is_login_post 1;\n        }\n\n        # Apply limit only when needed\n        if ($is_login_post = 1) {\n            limit_req zone=login_zone burst=2 nodelay;\n        }\n\n        include fastcgi_params;\n        # ...your fastcgi_pass to PHP-FPM here...\n    }\n\n    location = \/xmlrpc.php {\n        if ($wp_skip_limit) { set $limit_bypass 1; }\n        if ($request_method = POST) { set $is_xmlrpc_post 1; }\n\n        # Stricter on XML-RPC POSTs\n        if ($is_xmlrpc_post = 1) {\n            limit_req zone=xmlrpc_zone burst=5 nodelay;\n        }\n\n        # Optional: block the notorious pingback.ping without breaking all XML-RPC\n        # Quick-and-dirty content sniff (lightweight but not perfect):\n        # if ($request_body ~* &quot;pingback.ping&quot;) { return 403; }\n\n        include fastcgi_params;\n        # ...your fastcgi_pass to PHP-FPM here...\n    }\n}\n<\/code><\/pre>\n<p>I tend to avoid heavy request body inspections in Nginx for performance, but a small match like pingback.ping can be surprisingly effective. If you don\u2019t use XML-RPC at all, the simplest move is to deny it entirely:<\/p>\n<pre class=\"language-nginx line-numbers\"><code class=\"language-nginx\">location = \/xmlrpc.php { return 403; }\n<\/code><\/pre>\n<p>Just be sure you\u2019re not using Jetpack, mobile app publishing, or anything else that depends on it before flipping that switch.<\/p>\n<h3><span id=\"Why_nodelay\">Why nodelay?<\/span><\/h3>\n<p>Using <strong>nodelay<\/strong> means Nginx won\u2019t queue requests\u2014it will reject extra ones immediately with 429 instead of slowly buffering them. In brute force land, immediate rejection is kinder to your server. Tools and humans can always try again later.<\/p>\n<p>If you want to go deeper into the knobs and dials, the official <a href=\"https:\/\/nginx.org\/en\/docs\/http\/ngx_http_limit_req_module.html\" rel=\"nofollow noopener\" target=\"_blank\">Nginx rate limiting docs<\/a> give you the full story.<\/p>\n<h2 id=\"section-5\"><span id=\"Fail2ban_the_patient_bouncer_who_remembers_faces\">Fail2ban: the patient bouncer who remembers faces<\/span><\/h2>\n<p>Nginx has drawn the line in the sand. Now we\u2019ll ask Fail2ban to watch the logs for repeated abuse. If an IP keeps hitting rate limits (429) or forbidden zones (403), Fail2ban will add a firewall rule to drop traffic from that IP for a while. It\u2019s a time\u2011out for bots.<\/p>\n<h3><span id=\"Step_6_Install_Fail2ban_and_set_the_basics\">Step 6: Install Fail2ban and set the basics<\/span><\/h3>\n<p>On most Linux distros:<\/p>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\"># Debian\/Ubuntu\nsudo apt-get update &amp;&amp; sudo apt-get install -y fail2ban\n\n# RHEL\/CentOS\/Rocky\/Alma\nsudo dnf install -y fail2ban\n\n# Start and enable\nsudo systemctl enable --now fail2ban\n<\/code><\/pre>\n<p>If you\u2019re using nftables (which I like for modern setups), Fail2ban has nft actions built in. If you prefer iptables-legacy or are on a host with a managed firewall, adapt accordingly.<\/p>\n<h3><span id=\"Step_7_Create_dedicated_jails_for_wp-loginphp_and_xmlrpcphp\">Step 7: Create dedicated jails for wp-login.php and xmlrpc.php<\/span><\/h3>\n<p>We\u2019ll add two jails, one for each endpoint. They\u2019ll watch the Nginx access log, flag repeated errors, and ban the IP. Create a file at <strong>\/etc\/fail2ban\/jail.d\/wordpress-bruteforce.conf<\/strong>:<\/p>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\">[nginx-wp-login]\nenabled = true\nport    = http,https\nfilter  = nginx-wp-login\nlogpath = \/var\/log\/nginx\/access.log\nmaxretry = 5\nfindtime = 10m\nbantime = 1h\naction  = %(action_mwl)s\n# Suggestions: use nftables if available\n# action = nftables-multiport[name=wp-login, port=&quot;http,https&quot;]\n\n[nginx-xmlrpc]\nenabled = true\nport    = http,https\nfilter  = nginx-xmlrpc\nlogpath = \/var\/log\/nginx\/access.log\nmaxretry = 20\nfindtime = 10m\nbantime = 1h\naction  = %(action_mwl)s\n<\/code><\/pre>\n<p>We\u2019re allowing a few mistakes on login (5) and a few more for XML-RPC (20). You can tune these later. The idea is to catch machines that keep pushing past Nginx\u2019s limits. If you prefer progressively longer bans, enable bantime.increment in Fail2ban\u2019s configuration.<\/p>\n<h3><span id=\"Step_8_Add_filters_that_recognize_abuse_by_endpoint_and_status\">Step 8: Add filters that recognize abuse by endpoint and status<\/span><\/h3>\n<p>Now define filters to detect repeated 429\/403 on these endpoints. Create two files:<\/p>\n<p><strong>\/etc\/fail2ban\/filter.d\/nginx-wp-login.conf<\/strong><\/p>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\">[Definition]\nfailregex = ^&lt;HOST&gt; .* &quot;POST \/wp-login.php HTTP\/S+&quot; (403|429)\n            ^&lt;HOST&gt; .* &quot;GET \/wp-login.php HTTP\/S+&quot; (403|429)\n# Optionally also catch repeated 401 from basic auth if you protect \/wp-admin\nignoreregex =\n<\/code><\/pre>\n<p><strong>\/etc\/fail2ban\/filter.d\/nginx-xmlrpc.conf<\/strong><\/p>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\">[Definition]\nfailregex = ^&lt;HOST&gt; .* &quot;POST \/xmlrpc.php HTTP\/S+&quot; (403|429)\nignoreregex =\n<\/code><\/pre>\n<p>These patterns are built for the <strong>main_ext<\/strong> log format we defined earlier. If your format is different, adjust accordingly. The key idea: count repeated rate-limited or forbidden requests from the same IP to the specific endpoints.<\/p>\n<p>Restart Fail2ban to load the jails:<\/p>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\">sudo systemctl restart fail2ban\n<\/code><\/pre>\n<p>And confirm they\u2019re active:<\/p>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\">sudo fail2ban-client status\nsudo fail2ban-client status nginx-wp-login\nsudo fail2ban-client status nginx-xmlrpc\n<\/code><\/pre>\n<p>You\u2019ll see the number of currently banned IPs and the log file being watched. When bots keep poking, they\u2019ll get a short vacation outside your perimeter.<\/p>\n<h3><span id=\"Step_9_Optionalrecidivists_and_longer_bans\">Step 9: Optional\u2014recidivists and longer bans<\/span><\/h3>\n<p>Some botnets learn your limits and come back later. If you want to be firmer, try a recidive jail that watches all jails and hands out longer bans to repeat offenders. It\u2019s simple, but effective if you\u2019re tired of familiar faces in your logs.<\/p>\n<p>If you want a deeper dive into firewall policy and pushing Fail2ban bans into nftables with smart rate limits, my friendly walkthrough on <a href=\"https:\/\/www.dchost.com\/blog\/en\/nftables-ile-vps-guvenlik-duvari-rehberi-rate-limit-port-knocking-ve-ipv6-kurallari-nasil-tatli-tatli-kurulur\/\">nftables firewall rules without the drama<\/a> pairs nicely with this setup.<\/p>\n<h2 id=\"section-6\"><span id=\"XML-RPC_realities_when_to_block_when_to_throttle_and_how_to_be_kind\">XML-RPC realities: when to block, when to throttle, and how to be kind<\/span><\/h2>\n<p>Let\u2019s talk XML-RPC, because it\u2019s the one that trips folks up. If you\u2019re not using it, blocking it outright is clean and safe. But many sites do rely on it\u2014mobile apps, Jetpack, remote editors, and some security services need it.<\/p>\n<p>Here\u2019s a low\u2011drama approach I\u2019ve used: throttle XML-RPC POST requests with Nginx (as we did), <strong>and<\/strong> teach Fail2ban to ban IPs that keep tripping that limit. If you need certain services to bypass limits, allowlist their IPs temporarily using the map trick from earlier. I don\u2019t love IP allowlists for third parties, because they change. But in a pinch, it works.<\/p>\n<p>If the only XML-RPC method you actually need is something minimal, consider blocking specific methods known for abuse. The classic troublemaker is <strong>pingback.ping<\/strong>. Unfortunately, Nginx doesn\u2019t parse XML, so we either do a light content match or rely on the app layer to disable pingbacks. I\u2019ve set body matches for \u201cpingback.ping\u201d on small sites without issue. For high\u2011traffic sites, disabling pingbacks at the application level is cleaner and avoids scanning the body entirely.<\/p>\n<p>What about <em>system.multicall<\/em>? It\u2019s the Swiss army knife attackers use for batching. Rate limiting largely neuters it, because every POST is still a POST. If they squeeze hundreds of auth trials into one POST, that\u2019s on them\u2014you still counted 1 request. If that single request becomes expensive for PHP, you <em>might<\/em> block requests with very large bodies, or detect the method name via a short match and deny. I try rate limiting first; only escalate to body inspection when it\u2019s absolutely necessary.<\/p>\n<h2 id=\"section-7\"><span id=\"Tuning_without_hurting_real_users\">Tuning without hurting real users<\/span><\/h2>\n<p>Here\u2019s where experience pays off. You want to be tough, but not moody. I once rolled out an aggressive login limit for a WooCommerce site and heard from the support team within the hour: some customers behind a corporate proxy shared a public IP, and a burst of legitimate password resets hit the same limit. Oops. It wasn\u2019t a disaster, but it was a reminder.<\/p>\n<p>A few practical knobs you can turn:<\/p>\n<p>First, ease into the limits. Start with something like 5 requests\/min for wp-login.php and 15\/min for xmlrpc.php, then tighten if the bots keep chewing CPU. Don\u2019t push to extremes on day one. You can always turn the dial as you observe real behavior.<\/p>\n<p>Second, protect known flows. If you have a busy support team that logs into the dashboard from a single office IP, allowlist that IP or a VPN range using the map. That way, when everyone rushes to fix something at once, they don\u2019t trip over each other.<\/p>\n<p>Third, be kind to password reset flows. People do make mistakes typing passwords, especially on mobile. One or two retries is normal. Maxretry 5 within 10 minutes on Fail2ban is a forgiving baseline. If your user base is particularly non\u2011technical, consider stretching it a bit.<\/p>\n<p>Fourth, remember IPv6. Many bots use v4, but not all. Ensure your stack bans both families. Fail2ban\u2019s nftables actions handle v6 well. Double\u2011check that your web server logs show the real IPv6 addresses so the filters work properly.<\/p>\n<h2 id=\"section-8\"><span id=\"Testing_the_setup_like_a_friendly_attacker\">Testing the setup like a friendly attacker<\/span><\/h2>\n<p>Never deploy security changes without a quick dry run. I like to test in a controlled way with curl. For example, simulate a burst to wp-login.php:<\/p>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\"># Try multiple POSTs in a quick loop from your machine\nfor i in {1..10}; do \n  curl -s -o \/dev\/null -w &quot;%{http_code}n&quot; \n  -d &quot;log=admin&amp;pwd=guess$i&quot; \n  https:\/\/example.com\/wp-login.php; \n  sleep 0.2; \ndone\n<\/code><\/pre>\n<p>Watch for 200\/302 in the first few attempts (depending on how WordPress responds) followed by <strong>429<\/strong> as Nginx begins rate limiting. If you see only 200s forever, your limit likely didn\u2019t apply. Double\u2011check the location block, especially the conditional on POST requests.<\/p>\n<p>Then check Fail2ban status after a few minutes of abuse:<\/p>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\">sudo fail2ban-client status nginx-wp-login\n<\/code><\/pre>\n<p>You should see your IP banned (so maybe run the test from a safe IP you don\u2019t mind banning temporarily). Unban yourself easily:<\/p>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\">sudo fail2ban-client set nginx-wp-login unbanip &lt;your-ip&gt;\n<\/code><\/pre>\n<p>Repeat a similar test for xmlrpc.php. If you\u2019ve blocked pingbacks with the content match, try posting a payload containing \u201cpingback.ping\u201d to verify it returns 403.<\/p>\n<h2 id=\"section-9\"><span id=\"Monitoring_and_clean_logs_keep_it_visible_and_quiet\">Monitoring and clean logs: keep it visible and quiet<\/span><\/h2>\n<p>Nothing earns trust like clean observability. A few habits I swear by:<\/p>\n<p>First, keep an eye on 429 rates. An unexpected spike might mean a real feature is tripping limits. Maybe a mobile editor gone wild, or a new service that started using XML-RPC. Briefly bump the rate if needed while you investigate.<\/p>\n<p>Second, read your Fail2ban logs. You want to see a healthy rhythm\u2014bans happening here and there, not thousands of IPs getting banned out of nowhere. If bans explode, check your Nginx real IP settings first. Nine times out of ten, that\u2019s the culprit.<\/p>\n<p>Third, consider centralizing logs so you don\u2019t have to SSH into boxes at 2 a.m. A simple Loki + Promtail + Grafana setup gives you dashboards and quick searches by status code and endpoint. If that sounds appealing, I\u2019ve shared how I set it up with practical retention and alarms in <a href=\"https:\/\/www.dchost.com\/blog\/en\/vps-log-yonetimi-nasil-rayina-oturur-grafana-loki-promtail-ile-merkezi-loglama-tutma-sureleri-ve-alarm-kurallari\/\">this guide to centralized logging without the drama<\/a>.<\/p>\n<h2 id=\"section-10\"><span id=\"Edge_cases_CDNs_load_balancers_and_multiple_app_servers\">Edge cases: CDNs, load balancers, and multiple app servers<\/span><\/h2>\n<p>Real life is messy. If you run behind a CDN, make absolutely sure you restore client IPs with the correct header and <em>every<\/em> proxy range added to <strong>set_real_ip_from<\/strong>. Otherwise, you\u2019ll ban the CDN POP and take down real traffic with it. Been there, didn\u2019t love it. The Cloudflare guide I linked earlier is a good sanity check.<\/p>\n<p>If you use multiple app servers behind a load balancer, consider where to apply rate limiting. Per\u2011server limits can be fine, but a single client could be allowed N requests per minute per server. If you want global limits, put them at the load balancer or CDN edge. Nginx\u2019s shared memory zones are per worker on that machine\u2014they don\u2019t magically sync across nodes.<\/p>\n<p>Another subtlety: if you terminate TLS at a proxy and pass traffic to Nginx over plain HTTP, make sure logs still contain the original request path and method as usual. Fail2ban relies on that \u201cPOST \/wp-login.php\u201d string; if your logs are customized away from that format, adjust the filters to match.<\/p>\n<h2 id=\"section-11\"><span id=\"A_friendly_checklist_to_wrap_it_up\">A friendly checklist to wrap it up<\/span><\/h2>\n<p>Let\u2019s pull the thread together. Here\u2019s how I\u2019d roll this out calmly on a typical <a href=\"https:\/\/www.dchost.com\/vps\">VPS<\/a> running Nginx and PHP\u2011FPM:<\/p>\n<p>First, confirm Nginx sees real client IPs. If you use Cloudflare or a similar service, configure the real_ip_header and set_real_ip_from ranges. This one step prevents tragic, self\u2011inflicted bans.<\/p>\n<p>Second, add a clear log format and keep your access log in a predictable place. Fail2ban\u2019s power comes from reading these lines\u2014don\u2019t make it guess.<\/p>\n<p>Third, define limit_req zones for login and XML-RPC, and apply them only to POST requests on those exact locations. Keep the initial rates reasonable; adjust later with data.<\/p>\n<p>Fourth, create Fail2ban jails that look for repeated 429 or 403 responses on those endpoints. Set findtime and bantime to something humane, and consider bantime.increment if you want repeat offenders to stay out longer.<\/p>\n<p>Fifth, test it. Trigger a few 429s on purpose, watch Fail2ban ban you, then unban. Tweak the rates and retries until you\u2019re confident you\u2019re defending without annoying real users.<\/p>\n<p>Sixth, keep an eye on it. If you see spiky 429s or a sudden wave of bans, investigate. Usually it\u2019s a misconfig, but sometimes a real campaign is underway. Either way, you\u2019ll know.<\/p>\n<h2 id=\"section-12\"><span id=\"Common_tweaks_and_niceties_Ive_learned_over_time\">Common tweaks and niceties I\u2019ve learned over time<\/span><\/h2>\n<p>Here are a few more little tricks that have served me well:<\/p>\n<p>Consider adding a separate access log just for the sensitive endpoints. It makes Fail2ban filters simpler and your troubleshooting faster. For example:<\/p>\n<pre class=\"language-nginx line-numbers\"><code class=\"language-nginx\">access_log \/var\/log\/nginx\/auth_endpoints.log main_ext;\n\nlocation = \/wp-login.php {\n    access_log \/var\/log\/nginx\/auth_endpoints.log main_ext;\n    # ... rest of the config ...\n}\n\nlocation = \/xmlrpc.php {\n    access_log \/var\/log\/nginx\/auth_endpoints.log main_ext;\n    # ... rest of the config ...\n}\n<\/code><\/pre>\n<p>This way, your main access log stays cleaner, and Fail2ban can watch a small file that rolls faster.<\/p>\n<p>If you\u2019re worried about accidental lockouts from your own team, publish a tiny \u201cbreak glass\u201d procedure: what to do if someone gets banned. It might be as simple as \u201cmessage the on\u2011call\u201d and run one command to unban the IP. Moving fast when it happens turns a panic into a non\u2011event.<\/p>\n<p>If you want to get fancy, you can feed Fail2ban decisions into your firewall at a lower level with nftables sets for even cheaper packet drops. That\u2019s overkill for many sites, but glorious for busy ones.<\/p>\n<p>And finally, keep your WordPress itself sane: strong passwords, 2FA for admins, limit administrator accounts, and consider a login path change only if your staff won\u2019t be confused by it. I don\u2019t rely on security through obscurity, but making the door a little less obvious can reduce noise for smaller sites.<\/p>\n<h2 id=\"section-13\"><span id=\"A_quick_word_on_documentation_and_going_deeper\">A quick word on documentation and going deeper<\/span><\/h2>\n<p>When I first stitched this together years ago, I hopped between documentation pages with a notepad full of scribbles. These days, it\u2019s more muscle memory, but I still appreciate crisp references. If you want to read more straight from the source, the <a href=\"https:\/\/nginx.org\/en\/docs\/http\/ngx_http_limit_req_module.html\" rel=\"nofollow noopener\" target=\"_blank\">Nginx limit_req docs<\/a> and the <a href=\"https:\/\/www.fail2ban.org\/wiki\/index.php\/Main_Page\" rel=\"nofollow noopener\" target=\"_blank\">Fail2ban documentation<\/a> are solid places to dip into details or explore alternative actions and jail configurations.<\/p>\n<h2 id=\"section-14\"><span id=\"Wrapup_a_calmer_WordPress_without_the_drama\">Wrap\u2011up: a calmer WordPress, without the drama<\/span><\/h2>\n<p>When I think back to that noisy log and the admin pages that felt like they were wading through syrup, I\u2019m reminded how effective small, focused changes can be. You don\u2019t need a sprawling security stack to tame brute force noise on WordPress. A thoughtful combination of <strong>Nginx rate limiting<\/strong> and <strong>Fail2ban<\/strong> shuts down most of the nonsense before it ever tickles PHP.<\/p>\n<p>Start with real client IPs, add targeted limits to wp-login.php and xmlrpc.php, and let Fail2ban handle chronic offenders. Test gently, tune based on what you see, and keep your logs close at hand. If XML-RPC is essential for your workflow, throttle it kindly and consider blocking just the risky methods. And if you want to push bans deeper into the firewall or keep an eye on everything centrally, you\u2019ve got clear next steps.<\/p>\n<p>Hope this was helpful! If you try this setup and hit a weird edge case, I\u2019d love to hear about it\u2014there\u2019s always a new trick to learn. Until then, may your logs be quiet and your admin snappy.<\/p>\n<\/div>","protected":false},"excerpt":{"rendered":"<p>So there I was, coffee in hand, reading through yet another noisy access log. Lines and lines of POST \/wp-login.php requests, all from random IPs that had never bought a thing, never read a blog post, never even loaded the homepage. If you\u2019ve ever managed a WordPress site for more than a week, I bet [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":1587,"comment_status":"","ping_status":"","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[26],"tags":[],"class_list":["post-1585","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-teknoloji"],"_links":{"self":[{"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/posts\/1585","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/comments?post=1585"}],"version-history":[{"count":0,"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/posts\/1585\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/media\/1587"}],"wp:attachment":[{"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/media?parent=1585"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/categories?post=1585"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/tags?post=1585"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}