<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Calin V.</title>
    <description>The latest articles on DEV Community by Calin V. (@cifi).</description>
    <link>https://dev.to/cifi</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3658259%2F02bbd431-c075-4dfc-ba6c-6a7613be99b4.jpg</url>
      <title>DEV Community: Calin V.</title>
      <link>https://dev.to/cifi</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/cifi"/>
    <language>en</language>
    <item>
      <title>Give Yourself a Dead Man's Switch Before You Harden WordPress</title>
      <dc:creator>Calin V.</dc:creator>
      <pubDate>Wed, 16 Sep 2026 17:56:43 +0000</pubDate>
      <link>https://dev.to/cifi/give-yourself-a-dead-mans-switch-before-you-harden-wordpress-324o</link>
      <guid>https://dev.to/cifi/give-yourself-a-dead-mans-switch-before-you-harden-wordpress-324o</guid>
      <description>&lt;p&gt;Hardening a WordPress site is a handful of settings and about five minutes of work. The reason people put it off for a year is not the five minutes. It is that one of those settings changes the URL of your login page, and if the rewrite rules do not land the way the interface believes they did, the next thing you see is a 404 where your dashboard used to be.&lt;/p&gt;

&lt;p&gt;The usual advice is to be careful. Being careful does not scale and it does not survive a cache layer you forgot about.&lt;/p&gt;

&lt;p&gt;What follows is the pre-flight I run instead. It takes about ninety seconds to set up and it makes a bad change expire on its own, so the worst outcome of a mistake is a ten minute wait rather than an FTP session at midnight. Everything runs against a site you own and already have shell access to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why bother, on a five minute job
&lt;/h2&gt;

&lt;p&gt;Because the thing you are switching on is doing more work than the layer underneath it, so it is worth getting switched on properly and left on.&lt;/p&gt;

&lt;p&gt;According to Patchstack's &lt;em&gt;State of WordPress Security in 2026&lt;/em&gt;, 11,334 vulnerabilities were disclosed across the WordPress ecosystem during 2025, up 42% year on year, with 91% of them in plugins. Forty-six percent had no patch available at the moment of disclosure, and the weighted median time from disclosure to first exploitation was five hours.&lt;/p&gt;

&lt;p&gt;The same report pentested common hosting defences. Internal WAFs, Cloudflare, Imunify360 and ModSecurity blocked 12% of attacks against known-exploited vulnerabilities, and 26% on a broader test. The best host in the set blocked 60.7%. Several blocked under 17%. One blocked nothing.&lt;/p&gt;

&lt;p&gt;So the hardening layer matters, and the failure mode that actually destroys it is not an attacker. It is a site owner who broke something once, could not tell what, and turned the whole thing off.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: a restore point that takes two commands
&lt;/h2&gt;

&lt;p&gt;Do this before anything else. It is cheap enough that there is no argument against it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; /var/www/example.com/public_html
&lt;span class="nv"&gt;STAMP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F-%H%M%S&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# The rewrite rules, which is what a path change actually writes.&lt;/span&gt;
&lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; .htaccess &lt;span class="s2"&gt;"/root/restore/htaccess.&lt;/span&gt;&lt;span class="nv"&gt;$STAMP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;

&lt;span class="c"&gt;# Options only. Full DB dumps are slow enough that people skip them, and&lt;/span&gt;
&lt;span class="c"&gt;# every setting you are about to change lives in wp_options.&lt;/span&gt;
wp db &lt;span class="nb"&gt;export&lt;/span&gt; &lt;span class="s2"&gt;"/root/restore/options.&lt;/span&gt;&lt;span class="nv"&gt;$STAMP&lt;/span&gt;&lt;span class="s2"&gt;.sql"&lt;/span&gt; &lt;span class="nt"&gt;--tables&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;wp db prefix &lt;span class="nt"&gt;--allow-root&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;options
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;wp db prefix&lt;/code&gt; matters there because the prefix is not always &lt;code&gt;wp_&lt;/code&gt;, and a restore aimed at the wrong table name fails at exactly the moment you are least able to debug it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: the dead man's switch
&lt;/h2&gt;

&lt;p&gt;This is the part worth stealing. Schedule a job that reverts everything in ten minutes, then cancel it once you have confirmed the site is fine. If you lock yourself out, you do nothing at all and the site comes back.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/root/restore/revert.sh &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
#!/usr/bin/env bash
set -euo pipefail
SITE=/var/www/example.com/public_html
LATEST_HT=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-1t&lt;/span&gt; /root/restore/htaccess.&lt;span class="k"&gt;*&lt;/span&gt; 2&amp;gt;/dev/null | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;
[ -n "&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;LATEST_HT&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="sh"&gt;" ] &amp;amp;&amp;amp; cp -a "&lt;/span&gt;&lt;span class="nv"&gt;$LATEST_HT&lt;/span&gt;&lt;span class="sh"&gt;" "&lt;/span&gt;&lt;span class="nv"&gt;$SITE&lt;/span&gt;&lt;span class="sh"&gt;/.htaccess"
cd "&lt;/span&gt;&lt;span class="nv"&gt;$SITE&lt;/span&gt;&lt;span class="sh"&gt;"
# Substitute the slug of the plugin you are configuring.
wp plugin deactivate YOUR-PLUGIN-SLUG --allow-root || true
wp rewrite flush --hard --allow-root || true
logger -t wp-preflight "auto-revert fired"
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nb"&gt;chmod&lt;/span&gt; +x /root/restore/revert.sh

&lt;span class="c"&gt;# Arm it. `at` is in most distro repos; install it if the command is missing.&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"/root/restore/revert.sh"&lt;/span&gt; | at now + 10 minutes
atq            &lt;span class="c"&gt;# note the job number&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now go and make your change. When the site is confirmed healthy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;atrm &amp;lt;job-number&amp;gt;     &lt;span class="c"&gt;# disarm&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;at&lt;/code&gt; is not available, a one-shot systemd timer does the same thing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemd-run &lt;span class="nt"&gt;--on-active&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10min &lt;span class="nt"&gt;--unit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;wp-preflight-revert /root/restore/revert.sh
&lt;span class="c"&gt;# disarm with:&lt;/span&gt;
systemctl stop wp-preflight-revert.timer 2&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; systemctl reset-failed wp-preflight-revert.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ten minutes is the number I use because it is long enough to load a preset, purge caches and open the dashboard, and short enough that waiting it out is never worse than debugging under pressure. Re-arm it before each subsequent change rather than setting one long window.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: a watchdog in a second shell
&lt;/h2&gt;

&lt;p&gt;Open another terminal before you touch anything and leave this running for the whole change window. It tells you the moment something stops answering, and it tells you which thing, which is the information the browser withholds while it sits on a spinner.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;SITE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"https://example.com"&lt;/span&gt;
&lt;span class="nv"&gt;LOGIN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/wp-login.php"&lt;/span&gt;          &lt;span class="c"&gt;# change to your new path after the switch&lt;/span&gt;

&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s '&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%H:%M:%S&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;U &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"/"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOGIN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"/wp-admin/"&lt;/span&gt; &lt;span class="s2"&gt;"/wp-json/"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
    &lt;/span&gt;&lt;span class="nv"&gt;code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}'&lt;/span&gt; &lt;span class="nt"&gt;--max-time&lt;/span&gt; 5 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE$U&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
    &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s=%s '&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$U&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;done
  &lt;/span&gt;&lt;span class="nb"&gt;echo
  sleep &lt;/span&gt;5
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the row, not a single number. Homepage 200 with the login path 404 is a path change that landed and a bookmark that did not, which is fine. Everything 200 except &lt;code&gt;/wp-json/&lt;/code&gt; is a REST route that did not get a matching rule, and it will show up later as saves failing silently. Homepage 500 is the rewrite file, and that is the case where you stop touching things and let the timer fire.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: WP-CLI is your out-of-band control plane
&lt;/h2&gt;

&lt;p&gt;The reason a lockout feels catastrophic is that people assume the dashboard is the only way in. It is not, and knowing that in advance changes how much risk a path change actually carries.&lt;/p&gt;

&lt;p&gt;WP-CLI does not go through HTTP. Rewrite rules are irrelevant to it. If the web-facing side of the site is unreachable, the command line still works completely.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp plugin list &lt;span class="nt"&gt;--status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;active &lt;span class="nt"&gt;--field&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;name         &lt;span class="c"&gt;# what is on&lt;/span&gt;
wp plugin deactivate YOUR-PLUGIN-SLUG               &lt;span class="c"&gt;# instant off, no dashboard needed&lt;/span&gt;
wp option get home&lt;span class="p"&gt;;&lt;/span&gt; wp option get siteurl           &lt;span class="c"&gt;# confirm nothing rewrote these&lt;/span&gt;
wp rewrite flush &lt;span class="nt"&gt;--hard&lt;/span&gt;                             &lt;span class="c"&gt;# regenerate rules, no settings changed&lt;/span&gt;
wp user list &lt;span class="nt"&gt;--role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;administrator &lt;span class="nt"&gt;--fields&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;user_login,user_email
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The FTP-era equivalent, for hosts with no shell, is renaming the plugin directory. WordPress deactivates a plugin whose folder has vanished, and it does it without touching your database.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mv &lt;/span&gt;wp-content/plugins/your-plugin-slug wp-content/plugins/your-plugin-slug.off
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both routes are worth testing once on a staging copy, because the first time you use a recovery procedure should not be the first time you read it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: confirm the rules landed, not that you saved them
&lt;/h2&gt;

&lt;p&gt;A saved setting and an applied rewrite rule are different objects. On Apache and LiteSpeed they usually coincide. On nginx they frequently do not, because plugins write &lt;code&gt;.htaccess&lt;/code&gt; and nginx has never read &lt;code&gt;.htaccess&lt;/code&gt;. The file gets written, the interface reports success, and the server carries on serving the old routes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'^server:'&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'RewriteRule'&lt;/span&gt; .htaccess

&lt;span class="c"&gt;# On nginx, the rules have to be in the server block instead:&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;nginx &lt;span class="nt"&gt;-T&lt;/span&gt; 2&amp;gt;/dev/null | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s1"&gt;'rewrite '&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-20&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;nginx &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl reload nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;nginx -t&lt;/code&gt; before every reload, without exception. A reload on a bad config is an outage, and an outage during a hardening window is the thing that convinces people hardening caused it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: find and store the bypass route
&lt;/h2&gt;

&lt;p&gt;Most plugins in this category generate a bypass parameter, a unique string that suspends path changes for a single request so you can reach the dashboard from outside your own configuration. It is generated at install time and it lives on a settings page, which is to say it lives behind the login form you may be about to move.&lt;/p&gt;

&lt;p&gt;Look it up now, while you can still reach it, and put it in your password manager rather than a text file. Treat it as a credential: anyone holding it walks straight past your path security.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Find where your plugin keeps its config, then read the relevant key.&lt;/span&gt;
wp option list &lt;span class="nt"&gt;--search&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'*YOURPREFIX*'&lt;/span&gt; &lt;span class="nt"&gt;--fields&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;option_name,option_value | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-40&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your plugin does not have one, its documented equivalents are the temporary pause from the plugins screen and the folder rename above. Know which you have before you need it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The whole pre-flight
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. restore point&lt;/span&gt;
&lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; .htaccess /root/restore/htaccess.&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F-%H%M%S&lt;span class="si"&gt;)&lt;/span&gt;
wp db &lt;span class="nb"&gt;export&lt;/span&gt; /root/restore/options.&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F-%H%M%S&lt;span class="si"&gt;)&lt;/span&gt;.sql &lt;span class="nt"&gt;--tables&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;wp db prefix&lt;span class="si"&gt;)&lt;/span&gt;options

&lt;span class="c"&gt;# 2. arm the dead man's switch&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; /root/restore/revert.sh | at now + 10 minutes

&lt;span class="c"&gt;# 3. start the watchdog in a second shell&lt;/span&gt;
&lt;span class="c"&gt;# 4. make exactly one change&lt;/span&gt;
&lt;span class="c"&gt;# 5. confirm the row is green, then: atrm &amp;lt;job&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One change per window. That is the part people skip, and it is the part that turns a five second fix into an afternoon, because two simultaneous changes give you no way to attribute a failure to either of them.&lt;/p&gt;

&lt;p&gt;None of this makes your configuration more secure. It makes being wrong cheap, and a hardening layer you are willing to touch is worth considerably more than a stronger one you are afraid to open.&lt;/p&gt;

&lt;p&gt;What is in your pre-flight that is not in mine? I am particularly interested in anyone who has automated the cache purge into the same window, because that is the step I still do by hand and still occasionally forget.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>devops</category>
      <category>bash</category>
      <category>security</category>
    </item>
    <item>
      <title>Your Page Builder Did Not Break. One of Three Endpoints Stopped Resolving.</title>
      <dc:creator>Calin V.</dc:creator>
      <pubDate>Wed, 16 Sep 2026 17:47:20 +0000</pubDate>
      <link>https://dev.to/cifi/your-page-builder-did-not-break-one-of-three-endpoints-stopped-resolving-1lg1</link>
      <guid>https://dev.to/cifi/your-page-builder-did-not-break-one-of-three-endpoints-stopped-resolving-1lg1</guid>
      <description>&lt;p&gt;A page builder editor that hangs on the loading spinner, or saves and then reports failure with no error worth reading, is almost never a security problem. It is a routing problem with a security plugin's fingerprints on it, and the reason it is hard to fix from the dashboard is that the dashboard cannot tell you which request stopped resolving.&lt;/p&gt;

&lt;p&gt;Three endpoints decide this. &lt;code&gt;/wp-admin/&lt;/code&gt;, &lt;code&gt;admin-ajax.php&lt;/code&gt;, and the REST API at &lt;code&gt;/wp-json/&lt;/code&gt;. Every builder editor I have had to debug uses all three, in that order, and a security plugin that changes site paths can move any of them. What follows is how to find out which one moved without uninstalling anything.&lt;/p&gt;

&lt;p&gt;All of it runs against a site you own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Probe all three from outside the session
&lt;/h2&gt;

&lt;p&gt;You do not need an authenticated cookie for the first pass. All three endpoints answer an anonymous request in a distinctive way, and the shape of the answer tells you whether the path still routes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;SITE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"https://example.com"&lt;/span&gt;

&lt;span class="c"&gt;# 1. wp-admin: an anonymous GET should 302 to the login form.&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code} -&amp;gt; %{redirect_url}\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE&lt;/span&gt;&lt;span class="s2"&gt;/wp-admin/"&lt;/span&gt;

&lt;span class="c"&gt;# 2. admin-ajax.php: no action parameter, so WordPress answers "0" with a 400.&lt;/span&gt;
&lt;span class="c"&gt;#    The status is the point. A 404 here means the path is gone.&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE&lt;/span&gt;&lt;span class="s2"&gt;/wp-admin/admin-ajax.php"&lt;/span&gt;

&lt;span class="c"&gt;# 3. REST API index: an anonymous GET returns the route index as JSON.&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE&lt;/span&gt;&lt;span class="s2"&gt;/wp-json/"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it like this. A &lt;code&gt;302&lt;/code&gt; from the first, a &lt;code&gt;400&lt;/code&gt; from the second and a &lt;code&gt;200&lt;/code&gt; from the third means all three default paths still resolve, and your editor problem is somewhere else. Cache, most likely. Skip to the cached-&lt;code&gt;ajaxurl&lt;/code&gt; section.&lt;/p&gt;

&lt;p&gt;Any &lt;code&gt;404&lt;/code&gt; in that set is your answer. That endpoint has been changed and the server is not routing the new address to the old handler.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;403&lt;/code&gt; is a different animal and means a rule matched, not that the path moved. A firewall ruleset running at the rewrite layer will return 403 on a pattern match, and the editor's save payload can trip a rule written for SQL injection if it contains something that looks like one. If you get 403 on &lt;code&gt;admin-ajax.php&lt;/code&gt; but 200 on &lt;code&gt;/wp-json/&lt;/code&gt;, look at your ruleset before you look at your paths.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tell a rewrite-layer 404 from a PHP 404
&lt;/h2&gt;

&lt;p&gt;This distinction decides where you go looking, and it is measurable.&lt;/p&gt;

&lt;p&gt;A rewrite-layer 404 is returned by Apache, LiteSpeed or nginx before PHP is invoked. A WordPress 404 is a full page load: bootstrap, database connection, template. The second one costs an order of magnitude more time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Compare a known static asset, a known WordPress 404, and the endpoint under test.&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;U &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"/favicon.ico"&lt;/span&gt; &lt;span class="s2"&gt;"/this-page-does-not-exist-9182"&lt;/span&gt; &lt;span class="s2"&gt;"/wp-admin/admin-ajax.php"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%-42s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$U&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}  ttfb=%{time_starttransfer}s  size=%{size_download}\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE$U&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A response that lands near the static file's TTFB with a tiny body never reached PHP, and the fix is in your rewrite rules. A response that lands near the WordPress 404's TTFB, with a full HTML page in the body, means PHP ran and WordPress itself did not recognise the request, which usually means a filter is rewriting the URL in output but no server rule was written to match it.&lt;/p&gt;

&lt;p&gt;You can confirm from headers too. WordPress sets &lt;code&gt;X-Robots-Tag&lt;/code&gt; and a &lt;code&gt;Link&lt;/code&gt; header on real page loads that a server-level 404 will not carry.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE&lt;/span&gt;&lt;span class="s2"&gt;/wp-admin/admin-ajax.php"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-Ei&lt;/span&gt; &lt;span class="s1"&gt;'server|link|x-robots|x-powered-by'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The nginx trap
&lt;/h2&gt;

&lt;p&gt;If your site runs nginx and the editor broke immediately after a path change, this is the cause more often than everything else combined.&lt;/p&gt;

&lt;p&gt;Plugins in this category write rewrite rules into &lt;code&gt;.htaccess&lt;/code&gt;. nginx does not read &lt;code&gt;.htaccess&lt;/code&gt;. It never has, there is no module for it, and the file sits on disk being ignored while the plugin's interface reports that the rules were written. The plugin is not lying; it wrote the file. The server is not reading it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# On the server. If this returns rules and you are on nginx, they are doing nothing.&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nt"&gt;-A20&lt;/span&gt; &lt;span class="s1"&gt;'BEGIN'&lt;/span&gt; /var/www/example.com/public_html/.htaccess

&lt;span class="c"&gt;# What is actually serving you:&lt;/span&gt;
curl &lt;span class="nt"&gt;-sI&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'^server:'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rules have to go into the server block and the service has to be reloaded. The generic shape, for a plugin that has moved a path and given you the mapping:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="p"&gt;~&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt; &lt;span class="s"&gt;^/your-new-ajax-path/?&lt;/span&gt;$ &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;rewrite&lt;/span&gt; &lt;span class="s"&gt;^&lt;/span&gt; &lt;span class="n"&gt;/wp-admin/admin-ajax.php&lt;/span&gt; &lt;span class="s"&gt;last&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# and for a relocated REST namespace&lt;/span&gt;
&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="p"&gt;~&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt; &lt;span class="s"&gt;^/your-new-api-path/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;rewrite&lt;/span&gt; &lt;span class="s"&gt;^/your-new-api-path/(.*)&lt;/span&gt;$ &lt;span class="n"&gt;/wp-json/&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt; &lt;span class="s"&gt;last&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;nginx &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl reload nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Test the config before reloading, every time. &lt;code&gt;nginx -t&lt;/code&gt; is the difference between a reload and an outage.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stale ajaxurl nobody looks for
&lt;/h2&gt;

&lt;p&gt;This is the failure that survives a correct configuration, and it is worth knowing because the probes above will all come back clean while the editor stays broken.&lt;/p&gt;

&lt;p&gt;WordPress prints the AJAX endpoint into the page as a JavaScript variable, and builders read it from there rather than constructing it. If a full-page cache captured the HTML before the path change, or a JS concatenator inlined the old value into a bundle, the browser is sending editor requests to an address that no longer exists while the server is perfectly happy to route the new one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# What the served HTML currently tells the browser the AJAX endpoint is:&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oE&lt;/span&gt; &lt;span class="s1"&gt;'(var )?ajaxurl[^;]{0,120};'&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt;

&lt;span class="c"&gt;# And what your builder's own localised config carries:&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oE&lt;/span&gt; &lt;span class="s1"&gt;'"ajaxurl":"[^"]+"'&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If either of those still shows the pre-change path, no amount of rewrite work will help. Purge in this order, because purging out of order just repopulates the layer you cleaned: CDN first, then server-level cache, then the WordPress cache plugin, then the browser. Any concatenated or minified JS bundles have to be regenerated, not just evicted.&lt;/p&gt;

&lt;p&gt;While you are there, check the enqueued asset URLs too. A builder that loads its editor JS from a plugin directory whose name has been changed in output, without a matching server rule, produces a spinner that never resolves and zero errors in the PHP log, because the request never reached PHP.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oE&lt;/span&gt; &lt;span class="s1"&gt;'src="[^"]+\.js[^"]*"'&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-20&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Snapshot first, then bisect
&lt;/h2&gt;

&lt;p&gt;Take the restore point before you change anything else. It is two commands and it converts every subsequent step from a risk into an experiment.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; /var/www/example.com/public_html
&lt;span class="nb"&gt;cp&lt;/span&gt; .htaccess &lt;span class="s2"&gt;".htaccess.&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F-%H%M&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
wp db &lt;span class="nb"&gt;export&lt;/span&gt; &lt;span class="s2"&gt;"pre-path-change-&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F-%H%M&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;.sql"&lt;/span&gt; &lt;span class="nt"&gt;--add-drop-table&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then revert one endpoint at a time, in this order, testing a real save in the editor between each step:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The AJAX endpoint.&lt;/strong&gt; Most builder failures are here, because the editor talks to it continuously and the URL is baked into output rather than resolved at request time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The REST API path.&lt;/strong&gt; Saving is what breaks, and it often breaks silently, which makes this the second most likely and the most annoying to diagnose.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The admin path.&lt;/strong&gt; On hosts that restrict what can be done with the admin directory, this one fails in ways that look like a permissions problem.
Find where your plugin keeps that configuration rather than guessing at option names:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Substitute your plugin's own option prefix for PREFIX. `wp plugin list` will remind you of the slug.&lt;/span&gt;
wp plugin list &lt;span class="nt"&gt;--status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;active &lt;span class="nt"&gt;--field&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;name
wp option list &lt;span class="nt"&gt;--search&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'*PREFIX*'&lt;/span&gt; &lt;span class="nt"&gt;--fields&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;option_name,option_value | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-40&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Between each revert, log out and back in through your current login path. Session cookies are issued against the paths that were in force at the time, and stale cookies produce save failures that look exactly like a routing problem but are not one. Then resave permalinks, which regenerates rewrite rules without changing any setting.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule that prevents all of this
&lt;/h2&gt;

&lt;p&gt;Anonymous traffic never touches these three endpoints. Automated scanning goes after the front end: plugin and theme directories, readme files, the login form, version strings in your page source. Changing those breaks the reconnaissance step of an automated attack. Changing &lt;code&gt;admin-ajax.php&lt;/code&gt; breaks your editor and adds close to nothing, because nothing hostile was looking there.&lt;/p&gt;

&lt;p&gt;So the configuration that costs you nothing and buys you most of the benefit is to change everything an unauthenticated request can see and leave the three endpoints your own tooling depends on exactly where they are.&lt;/p&gt;

&lt;p&gt;That is not a compromise position. According to Patchstack's &lt;em&gt;State of WordPress Security in 2026&lt;/em&gt;, 91% of the 11,334 vulnerabilities disclosed across the WordPress ecosystem in 2025 were in plugins, 46% of them had no patch available at the moment of disclosure, and the weighted median time from disclosure to first exploitation was five hours. Reducing what is reachable is the layer that does anything at all inside a five-hour window. A custom AJAX path is not part of that layer, and the same report's pentest of hosting defences, where internal WAFs, Cloudflare, Imunify360 and ModSecurity blocked 12% of attacks against known-exploited vulnerabilities and 26% on a broader test, is a reasonable prior for how much the layer underneath is contributing.&lt;/p&gt;

&lt;p&gt;A control that gets deactivated in July because it broke an editor is protecting nothing in August. The setting you leave alone is part of the security configuration.&lt;/p&gt;

&lt;p&gt;What is the endpoint failure that cost you the most time before you found it? I am after the one where every probe came back clean and the answer turned out to be somewhere else entirely.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>security</category>
      <category>webdev</category>
      <category>devops</category>
    </item>
    <item>
      <title>Verify Your WordPress Hardening From the Command Line Before You Trust It</title>
      <dc:creator>Calin V.</dc:creator>
      <pubDate>Thu, 03 Sep 2026 14:35:46 +0000</pubDate>
      <link>https://dev.to/cifi/verify-your-wordpress-hardening-from-the-command-line-before-you-trust-it-1edd</link>
      <guid>https://dev.to/cifi/verify-your-wordpress-hardening-from-the-command-line-before-you-trust-it-1edd</guid>
      <description>&lt;p&gt;Most WordPress hardening advice ends at "turn it on." Almost none of it tells you how to confirm the thing you turned on is doing anything, which is a problem, because the two most common outcomes of configuring a security plugin are a control that silently does nothing and a control that silently breaks your site.&lt;/p&gt;

&lt;p&gt;Both look identical from the dashboard. The panel says enabled either way.&lt;/p&gt;

&lt;p&gt;What follows is the check I run after configuring hardening on a site, control by control. It is four commands and a rollback drill, and it takes about ten minutes. Everything here runs against a site you own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why verification matters more than coverage here
&lt;/h2&gt;

&lt;p&gt;Patchstack's &lt;em&gt;State of WordPress Security in 2026&lt;/em&gt; put 11,334 vulnerabilities across the WordPress ecosystem in 2025, 91% of them in plugins and 9% in themes, with six in core, all low risk. The exposure is the code you installed.&lt;/p&gt;

&lt;p&gt;The number that decides your strategy is the next one: 46% of those had no patch available at disclosure, and the weighted median time to first exploitation was five hours. Patching is necessary and it is not, on its own, a plan. So you run a hardening layer underneath it, and the hardening layer is only worth what you can prove it does.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Prove the default paths return 404, and that PHP never ran
&lt;/h2&gt;

&lt;p&gt;The first control is reconfiguring the default endpoints so that a scan against &lt;code&gt;wp-login.php&lt;/code&gt;, &lt;code&gt;wp-admin&lt;/code&gt;, &lt;code&gt;wp-json&lt;/code&gt; and the plugin and theme directories fails at reconnaissance.&lt;/p&gt;

&lt;p&gt;Checking the status code is the easy half:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;SITE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"https://example.com"&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;p &lt;span class="k"&gt;in &lt;/span&gt;wp-login.php wp-admin/ wp-json/ wp-content/plugins/ xmlrpc.php&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}'&lt;/span&gt; &lt;span class="nt"&gt;-L&lt;/span&gt; &lt;span class="nt"&gt;--max-time&lt;/span&gt; 10 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SITE&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$p&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%-26s %s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$p&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A 404 or 403 on the paths you reconfigured is what you want. A 200 on &lt;code&gt;wp-login.php&lt;/code&gt; means the setting did not apply, which usually means a caching layer served you a stale page or the rewrite rules were never written.&lt;/p&gt;

&lt;p&gt;The harder and more useful half is finding out &lt;strong&gt;where&lt;/strong&gt; that 404 came from. A 404 emitted by the web server from a rewrite rule is a different security property from a 404 emitted by WordPress after it booted, loaded the plugin stack, and decided to deny you. The first one means the exploit path never executed. The second means every line of PHP ran and then politely declined.&lt;/p&gt;

&lt;p&gt;Two signals discriminate them. Headers first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sSI&lt;/span&gt; &lt;span class="s2"&gt;"https://example.com/wp-login.php"&lt;/span&gt; | &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-iE&lt;/span&gt; &lt;span class="s1"&gt;'HTTP/|server|x-powered-by|set-cookie|link|x-redirect-by'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A rewrite-layer rejection is bare: a status line, a server header, a content type, little else. If you see &lt;code&gt;x-powered-by: PHP/8.x&lt;/code&gt;, a &lt;code&gt;Set-Cookie&lt;/code&gt; carrying a WordPress cookie, or a &lt;code&gt;Link:&lt;/code&gt; header advertising the REST API, then WordPress ran. That is the application-layer version of the control.&lt;/p&gt;

&lt;p&gt;Then timing, against a known-static file as your baseline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;FMT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'%{http_code}  ttfb=%{time_starttransfer}s  total=%{time_total}s\n'&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$FMT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; https://example.com/robots.txt
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$FMT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; https://example.com/wp-login.php
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$FMT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; https://example.com/definitely-not-a-real-path-9f2a
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the blocked path's TTFB sits close to &lt;code&gt;robots.txt&lt;/code&gt;, the request died before PHP. If it sits close to a normal WordPress 404, WordPress handled it. Run each three or four times; a single sample on shared hosting tells you nothing.&lt;/p&gt;

&lt;p&gt;One caveat worth stating out loud: this is measuring where enforcement happens, not whether an attacker can guess the new path. Those are separate questions and only the first one is testable with curl.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Prove the firewall ruleset is actually loaded
&lt;/h2&gt;

&lt;p&gt;A 7G or 8G ruleset rejects injection strings, traversal attempts and malformed requests by pattern. On Apache and LiteSpeed those land as rewrite directives, so they are evaluated before PHP.&lt;/p&gt;

&lt;p&gt;Confirm the rules exist on disk first, rather than trusting the panel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Apache / LiteSpeed&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-nE&lt;/span&gt; &lt;span class="s1"&gt;'7G|8G|RewriteCond|RewriteRule'&lt;/span&gt; /path/to/site/.htaccess | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-40&lt;/span&gt;

&lt;span class="c"&gt;# nginx: rules live in the server block, not in .htaccess&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;nginx &lt;span class="nt"&gt;-T&lt;/span&gt; 2&amp;gt;/dev/null | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-nE&lt;/span&gt; &lt;span class="s1"&gt;'location|deny|return 403'&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-40&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On nginx this step matters more than people expect. Plenty of plugins write &lt;code&gt;.htaccess&lt;/code&gt; unconditionally, and on nginx that file is inert. The panel reports success, the rules are never read, and nothing in the UI says so.&lt;/p&gt;

&lt;p&gt;Then send a request that trips a pattern, against your own site:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}\n'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--get&lt;/span&gt; &lt;span class="nt"&gt;--data-urlencode&lt;/span&gt; &lt;span class="s1"&gt;'p=../../../../etc/passwd'&lt;/span&gt; https://example.com/

curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}\n'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--get&lt;/span&gt; &lt;span class="nt"&gt;--data-urlencode&lt;/span&gt; &lt;span class="s2"&gt;"s=1' UNION SELECT 1,2,3-- -"&lt;/span&gt; https://example.com/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;403 means the ruleset fired. 200 means the request reached WordPress and was handled as an ordinary query, which is not itself a vulnerability but does tell you the layer you thought was in front is not in front.&lt;/p&gt;

&lt;p&gt;Do not over-read a pass here. Patchstack's 2025 pentest of common defences, covering internal host WAFs, Cloudflare, Imunify360 and ModSecurity, found they blocked 12% of attacks against known-exploited vulnerabilities, rising to 26% on a broader test. The best host in the set reached 60.7%. One blocked nothing. A pattern ruleset that answers 403 to a traversal string is working as designed, and working as designed still leaves most real exploit traffic to something else.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Prove the login throttle counts, and find the number
&lt;/h2&gt;

&lt;p&gt;Rate limiting is the control most often enabled and least often verified, because verifying it means locking yourself out on purpose. Do that deliberately, on your own site, with a second browser session already open somewhere else.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;LOGIN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"https://example.com/your-configured-login-path"&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;seq &lt;/span&gt;1 12&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"log=verify-throttle-&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;pwd=wrong-on-purpose"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/x-www-form-urlencoded'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--max-time&lt;/span&gt; 10 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOGIN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"attempt &lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="s2"&gt; -&amp;gt; &lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;sleep &lt;/span&gt;1
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You are looking for the attempt number where the response changes: a 403, a 429, or a body that stops being the login form. That number is your actual lockout threshold, which is frequently not the number in the settings field, because a caching layer or a proxy in front can absorb attempts before they are counted.&lt;/p&gt;

&lt;p&gt;While you are here, check what the site does with &lt;code&gt;XML-RPC&lt;/code&gt;, which is a second credential endpoint that a login throttle configured only for the form may not cover:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://example.com/xmlrpc.php &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: text/xml'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;methodCall&amp;gt;&amp;lt;methodName&amp;gt;system.listMethods&amp;lt;/methodName&amp;gt;&amp;lt;params&amp;gt;&amp;lt;/params&amp;gt;&amp;lt;/methodCall&amp;gt;'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; 400
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A method list coming back means the endpoint is live. &lt;code&gt;system.multicall&lt;/code&gt; is the reason people care: it lets an attacker bundle many credential attempts into a single HTTP request, so a throttle that counts requests rather than attempts undercounts badly.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Prove 2FA covers the paths that actually carry credentials
&lt;/h2&gt;

&lt;p&gt;The Verizon 2025 Data Breach Investigations Report found 88% of Basic Web Application attacks involved stolen credentials, which is why a second factor is on the short list at all. Passkeys are the strongest form, because the challenge is bound to your domain and a phished credential cannot be replayed elsewhere.&lt;/p&gt;

&lt;p&gt;The gap worth checking is that 2FA usually protects the &lt;strong&gt;form&lt;/strong&gt;, and the form is not the only way credentials enter your site. Application passwords authenticate over REST and are unaffected by two-factor, captcha, throttling, or a reconfigured login path. They are issued once and do not expire on their own.&lt;/p&gt;

&lt;p&gt;List what exists:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp user list &lt;span class="nt"&gt;--field&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ID | &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"== user &lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  wp user application-password list &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;table 2&amp;gt;/dev/null
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then confirm the credential works without ever touching your login page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s1"&gt;'username:xxxx xxxx xxxx xxxx xxxx xxxx'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://example.com/wp-json/wp/v2/users/me | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; 300
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that returns your user object, then every login control you configured is out of the request path for that credential, by construction. Which is fine when you provisioned it deliberately and know it exists. It is a problem when it was issued for a mobile app in 2023 by somebody who has left.&lt;/p&gt;

&lt;p&gt;Revoke what you cannot account for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp user application-password delete &amp;lt;user-id&amp;gt; &amp;lt;uuid&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  5. Rehearse the rollback before you need it
&lt;/h2&gt;

&lt;p&gt;This is the step that decides whether any of the above survives, and it is the one nobody does.&lt;/p&gt;

&lt;p&gt;Find your recovery path while you can still log in, then actually run it once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# WP-CLI, if you have shell access&lt;/span&gt;
wp plugin deactivate &amp;lt;plugin-slug&amp;gt;
wp rewrite flush &lt;span class="nt"&gt;--hard&lt;/span&gt;

&lt;span class="c"&gt;# no shell: rename the plugin directory over SFTP&lt;/span&gt;
&lt;span class="nb"&gt;mv &lt;/span&gt;wp-content/plugins/&amp;lt;plugin-slug&amp;gt; wp-content/plugins/&amp;lt;plugin-slug&amp;gt;.off

&lt;span class="c"&gt;# and keep a known-good copy of the rewrite rules before you change anything&lt;/span&gt;
&lt;span class="nb"&gt;cp&lt;/span&gt; .htaccess .htaccess.pre-hardening
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to confirm during the drill. First, that deactivating restores the default paths immediately rather than leaving a half-applied rewrite state, which is the difference between a plugin that writes rules and one that also modifies core files. Second, that you know your vendor's documented bypass URL, if it has one, and that it is bookmarked somewhere you can reach from a phone.&lt;/p&gt;

&lt;p&gt;A control you cannot reverse under pressure is a control you will eventually disable permanently at 2am, and a permanently disabled control protects nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;Four commands and a drill:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;curl -I&lt;/code&gt; the default paths, and use TTFB against a static file to tell a rewrite-layer 404 from a PHP one.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;grep&lt;/code&gt; the actual server config for the ruleset, then trip one pattern and expect 403. On nginx, check that &lt;code&gt;.htaccess&lt;/code&gt; is not being written into the void.&lt;/li&gt;
&lt;li&gt;Loop failed logins until the status code changes, and find out whether the real threshold matches the configured one. Check &lt;code&gt;xmlrpc.php&lt;/code&gt; separately.&lt;/li&gt;
&lt;li&gt;List application passwords, prove one authenticates without the login form, and revoke what you cannot account for.
Then rehearse the rollback while you are still logged in.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What is the check you run that is not on this list? I am specifically after the one that caught something the dashboard reported as fine.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>security</category>
      <category>devops</category>
      <category>php</category>
    </item>
    <item>
      <title>Measure What a Security Plugin Costs You, Instead of Reading the Benchmark</title>
      <dc:creator>Calin V.</dc:creator>
      <pubDate>Sat, 29 Aug 2026 07:58:19 +0000</pubDate>
      <link>https://dev.to/cifi/measure-what-a-security-plugin-costs-you-instead-of-reading-the-benchmark-111o</link>
      <guid>https://dev.to/cifi/measure-what-a-security-plugin-costs-you-instead-of-reading-the-benchmark-111o</guid>
      <description>&lt;p&gt;Every security plugin vendor publishes a performance claim, and none of them are measuring your site. The host, the PHP version, the opcode cache, the traffic mix and whatever else is installed all move the result more than the plugin does. I am not going to give you a percentage in this post, because I do not have one I would defend in a code review.&lt;/p&gt;

&lt;p&gt;What I can give you is the method. Half an hour of setup produces numbers about your stack, and those numbers settle the argument that vendor benchmarks never will.&lt;/p&gt;

&lt;p&gt;Three things are worth measuring separately, because they behave differently and get confused constantly:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Per-request cost.&lt;/strong&gt; PHP executed on every hit, whether or not anything security-relevant happened.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write cost.&lt;/strong&gt; Rows inserted per event the plugin decides to record. Scales with traffic, not with legitimate traffic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storage cost.&lt;/strong&gt; What the accumulated records weigh, and specifically how much of it WordPress loads into memory on every single request.
The third one is where the hundred-megabyte options-table stories come from, and it is almost always a logging feature rather than a firewall.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Per-request cost: median TTFB, not one curl
&lt;/h2&gt;

&lt;p&gt;A single &lt;code&gt;curl -w&lt;/code&gt; is noise. Take the median of a run and compare medians.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# ttfb.sh - median time-to-first-byte over N requests&lt;/span&gt;
&lt;span class="c"&gt;# usage: ./ttfb.sh https://staging.example.com/ 40&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;1&lt;/span&gt;:?usage:&lt;span class="p"&gt; ttfb.sh &amp;lt;url&amp;gt; [n]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;N&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;2&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;40&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;TMP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;mktemp&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;_ &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;seq&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$N&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="se"&gt;\&lt;/span&gt;
       &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Cache-Control: no-cache'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
       &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{time_starttransfer}\n'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
       &lt;span class="nt"&gt;--max-time&lt;/span&gt; 20 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TMP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;sleep &lt;/span&gt;0.4
&lt;span class="k"&gt;done

&lt;/span&gt;&lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TMP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nv"&gt;n&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$N&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s1"&gt;'
  { v[NR] = $1 }
  END {
    printf "n=%d  min=%.3f  p50=%.3f  p90=%.3f  max=%.3f\n",
      NR, v[1], v[int(NR*0.5)+0], v[int(NR*0.9)+0], v[NR]
  }'&lt;/span&gt;
&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TMP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rules that make the output mean something:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Run it against &lt;strong&gt;staging&lt;/strong&gt;, not production, and against a URL your page cache does not serve. A cached response measures your cache, not your plugin stack.&lt;/li&gt;
&lt;li&gt;Send &lt;code&gt;Cache-Control: no-cache&lt;/code&gt; and pick a path with a query string if your cache is aggressive.&lt;/li&gt;
&lt;li&gt;Compare p50 and p90 from the same machine, at the same time of day, on the same host. Cross-host comparisons are worthless.
Now the A/B. Deactivate one plugin, re-measure, reactivate:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# ab-plugin.sh - measure with a plugin off, then back on&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail
&lt;span class="nv"&gt;URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;SLUG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$2&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"== baseline (all active)"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; ./ttfb.sh &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 40
wp plugin deactivate &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SLUG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"== without &lt;/span&gt;&lt;span class="nv"&gt;$SLUG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        ./ttfb.sh &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 40
wp plugin activate &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SLUG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"== restored"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;             ./ttfb.sh &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 40
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The third run matters. If "restored" does not land near "baseline", something else moved during the test and the middle number is not trustworthy.&lt;/p&gt;

&lt;p&gt;For attribution inside a single page load rather than a difference between two, Query Monitor breaks queries and hook time down per plugin on a live request, and the WP-CLI profile package does it from the shell:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp package &lt;span class="nb"&gt;install &lt;/span&gt;wp-cli/profile-command:@stable
wp profile stage &lt;span class="nt"&gt;--allow-root&lt;/span&gt;
wp profile hook &lt;span class="nt"&gt;--all&lt;/span&gt; &lt;span class="nt"&gt;--spotlight&lt;/span&gt; &lt;span class="nt"&gt;--orderby&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;time&lt;/span&gt; &lt;span class="nt"&gt;--order&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;desc | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-20&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Storage cost: aggregate autoload weight by plugin, not by option
&lt;/h2&gt;

&lt;p&gt;Total autoloaded weight first. This is what gets pulled into memory on every request, including requests that have nothing to do with security:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;LENGTH&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;option_value&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;autoload_bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                  &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;autoload_rows&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;wp_options&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;autoload&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'yes'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'on'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'auto'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'auto-on'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The accepted values in the &lt;code&gt;autoload&lt;/code&gt; column were expanded in recent WordPress releases, so the &lt;code&gt;IN&lt;/code&gt; list keeps this working across versions. Anything past a few hundred kilobytes is worth chasing.&lt;/p&gt;

&lt;p&gt;Listing the top twenty options is the usual next step, and it is fine, but it buries a plugin that autoloads two hundred small options under one plugin that autoloads a single large one. Aggregate by prefix instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;SUBSTRING_INDEX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;TRIM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;LEADING&lt;/span&gt; &lt;span class="s1"&gt;'_'&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;option_name&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;'_'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                                                    &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;rows_n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;ROUND&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;LENGTH&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;option_value&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                  &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;kb&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;wp_options&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;autoload&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'yes'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'on'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'auto'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'auto-on'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;prefix&lt;/span&gt;
&lt;span class="k"&gt;HAVING&lt;/span&gt; &lt;span class="n"&gt;kb&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;kb&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Option names carry the prefix of the plugin that created them, so this names the responsible plugin directly and ranks by what it actually costs rather than by the size of its largest single row. Core's own prefixes show up too, which is a useful sanity check on the query.&lt;/p&gt;

&lt;p&gt;Through WP-CLI, if you would rather not open a SQL client:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp option list &lt;span class="nt"&gt;--autoload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;on &lt;span class="nt"&gt;--fields&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;option_name,size_bytes &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;csv &lt;span class="se"&gt;\&lt;/span&gt;
| &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt;, &lt;span class="s1"&gt;'NR&amp;gt;1 { split($1, p, "_"); k = p[1] ? p[1] : p[2]; s[k] += $2 }
           END { for (i in s) printf "%10.1f KB  %s\n", s[i]/1024, i }'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
| &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-20&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And table sizes, where accumulated logs live rather than autoloaded settings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;table_rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;ROUND&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;data_length&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;index_length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;mb&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TABLES&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;table_schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data_length&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;index_length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to rule out before blaming security tooling. Transients that never expired are a common cause of options growth and usually come from caching or import plugins. And orphaned tables from plugins deleted years ago keep every row, because uninstalling removes the code and not the data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Write cost: measure the growth rate, not the size
&lt;/h2&gt;

&lt;p&gt;A table's current size tells you where you are. Its growth rate tells you where you will be in six months, and that is the number that decides whether a logging feature is sustainable on this site.&lt;/p&gt;

&lt;p&gt;Sample it on a schedule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# table-growth.sh - append a size sample; run hourly from cron&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail
&lt;span class="nv"&gt;LOG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/var/log/wp-table-sizes.csv
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"ts,table,mb,rows"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

wp db query &lt;span class="s2"&gt;"
  SELECT table_name, ROUND((data_length+index_length)/1024/1024,3), table_rows
  FROM information_schema.TABLES
  WHERE table_schema = DATABASE()
  ORDER BY (data_length+index_length) DESC LIMIT 15;"&lt;/span&gt; &lt;span class="nt"&gt;--skip-column-names&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
| &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nv"&gt;ts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%FT%T&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s1"&gt;'BEGIN{OFS=","} {print ts, $1, $2, $3}'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# crontab -e&lt;/span&gt;
17 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; /usr/local/bin/table-growth.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After a couple of days, bytes per day per table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt;, &lt;span class="s1"&gt;'NR&amp;gt;1 { if (!(($2) in first)) { first[$2]=$3; ft[$2]=$1 } last[$2]=$3; lt[$2]=$1 }
         END { for (t in last)
                 printf "%-40s %8.2f MB now  %+7.2f MB since %s\n",
                        t, last[t], last[t]-first[t], ft[t] }'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  /var/log/wp-table-sizes.csv | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-k2&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it across a weekday and a weekend. Log tables that grow with bot traffic have a very different curve from tables that grow with human activity, and knowing which one you have tells you whether a retention setting fixes it or whether you need the traffic to stop arriving.&lt;/p&gt;

&lt;p&gt;Before you delete anything: most logging features ship a retention or pruning setting that defaults to keeping everything, and that is the first place to look. Deleting options a plugin actively reads will break it, and some log tables are referenced by the plugin's own admin screens. Back up first.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the numbers will not tell you
&lt;/h2&gt;

&lt;p&gt;Two limits worth stating, because a set of scripts like this invites over-reading.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A low blocked-attack count on a dashboard is ambiguous.&lt;/strong&gt; An operator running fail2ban across two servers posted his jail counts on r/Wordpress this month: crawlers banned 915 and 1,129, the &lt;code&gt;wp-login&lt;/code&gt; jails 15,189 and 50,386, the &lt;code&gt;xmlrpc&lt;/code&gt; jails 11,345 and 9,530. Those are his counters on his servers, not a general measurement. His conclusion is the useful part and it generalises fine: those bans happen at the server before PHP starts, so no plugin sees that traffic and none of it appears in any dashboard. A low count can mean less attack traffic, or it can mean something upstream absorbed it. From inside WordPress those look identical.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cheap layers cannot see everything.&lt;/strong&gt; A rule evaluated before PHP starts can only answer questions that can be settled from the request itself. It does not know who is logged in or whether a user has the capability they are claiming. Broken access control was the largest single category Patchstack's RapidMitigate blocked in 2025, at 57% of attacks, precisely because it resembles normal authenticated traffic. Nothing at the rewrite layer catches that, and moving enforcement earlier trades visibility and context for cost.&lt;/p&gt;

&lt;p&gt;So this answers "what does this cost me", which is a real question with a measurable answer. It does not answer "is this the right control", which depends on what you are defending against.&lt;/p&gt;

&lt;h2&gt;
  
  
  One question
&lt;/h2&gt;

&lt;p&gt;If you have chased an options table into the hundreds of megabytes: what was actually at the top of the list when you aggregated by prefix? Mine has been a caching plugin more often than a security plugin, and I would like to know whether that holds for anyone else.&lt;/p&gt;

&lt;p&gt;I run engineering at Squirrly and spend more time in access logs than I would choose to. The article this accompanies, with the layer comparison in full, is here: &lt;a href="https://wpghost.com/lightweight-wordpress-security-plugin/" rel="noopener noreferrer"&gt;https://wpghost.com/lightweight-wordpress-security-plugin/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>devops</category>
      <category>security</category>
    </item>
    <item>
      <title>Start with an inventory you can diff against next year</title>
      <dc:creator>Calin V.</dc:creator>
      <pubDate>Sat, 29 Aug 2026 07:01:29 +0000</pubDate>
      <link>https://dev.to/cifi/start-with-an-inventory-you-can-diff-against-next-year-23bh</link>
      <guid>https://dev.to/cifi/start-with-an-inventory-you-can-diff-against-next-year-23bh</guid>
      <description>&lt;p&gt;WP-CLI already knows most of what you need.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp plugin list &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--fields&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;name,title,status,version,update,update_version,auto_update &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;csv &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"inventory-&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;.csv"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run that on every site and keep the files. A year of these is a version history you did not have to ask a vendor for, and the diff between two of them answers the renewal question directly.&lt;/p&gt;

&lt;p&gt;For a portfolio, fan it out over an alias group and tag each row with the site:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# inventory.sh - collect plugin state across an alias group into one CSV&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;OUT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"inventory-&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;.csv"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"site,name,version,update,update_version,status"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$OUT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;site &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;wp @all cli info &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;json 2&amp;gt;/dev/null | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'keys[]'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;wp &lt;span class="s2"&gt;"@&lt;/span&gt;&lt;span class="nv"&gt;$site&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; plugin list &lt;span class="se"&gt;\&lt;/span&gt;
     &lt;span class="nt"&gt;--fields&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;name,version,update,update_version,status &lt;span class="se"&gt;\&lt;/span&gt;
     &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;csv &lt;span class="nt"&gt;--skip-plugins&lt;/span&gt; &lt;span class="nt"&gt;--skip-themes&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="se"&gt;\&lt;/span&gt;
   | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; +2 &lt;span class="se"&gt;\&lt;/span&gt;
   | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s2"&gt;"s/^/&lt;/span&gt;&lt;span class="nv"&gt;$site&lt;/span&gt;&lt;span class="s2"&gt;,/"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$OUT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done

&lt;/span&gt;&lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$OUT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--skip-plugins --skip-themes&lt;/code&gt; keeps a broken plugin from taking the inventory run down with it. You want the census even when one site is unhappy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ask the directory what changed, for the plugins it knows about
&lt;/h2&gt;

&lt;p&gt;For anything hosted on wordpress.org, the public API will tell you the release history without any authentication.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;slug&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"query-monitor"&lt;/span&gt;

curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="s2"&gt;"https://api.wordpress.org/plugins/info/1.0/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;slug&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="s1"&gt;'{
      name,
      version,
      last_updated,
      active_installs,
      tested,
      requires_php,
      releases: (.versions | keys | map(select(. != "trunk")) | sort | .[-8:])
    }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two fields matter for a renewal decision. &lt;code&gt;last_updated&lt;/code&gt; tells you whether the product is being worked on at all. The tail of &lt;code&gt;versions&lt;/code&gt; tells you how many releases shipped in the period you paid for, which is a proxy for whether the value arrived as a stream or as a one-time setup.&lt;/p&gt;

&lt;p&gt;A wrapper over the whole inventory, so you get one table instead of forty curl calls:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# release-cadence.sh - how many releases has each .org plugin shipped, and when was the last one&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%-32s %-12s %-22s %s\n'&lt;/span&gt; SLUG INSTALLED LAST_UPDATED RELEASES_KNOWN

wp plugin list &lt;span class="nt"&gt;--field&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;name &lt;span class="nt"&gt;--status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;active | &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; slug&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="nt"&gt;--max-time&lt;/span&gt; 10 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="s2"&gt;"https://api.wordpress.org/plugins/info/1.0/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;slug&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.json"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'{}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

  &lt;span class="c"&gt;# a premium plugin is not in the directory and returns null or an error object&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$json&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'type'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"object"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$json&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.version // "null"'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"null"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%-32s %-12s %-22s %s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$slug&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;wp plugin get &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$slug&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--field&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;version&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"not-in-directory"&lt;/span&gt; &lt;span class="s2"&gt;"-"&lt;/span&gt;
    &lt;span class="k"&gt;continue
  fi

  &lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%-32s %-12s %-22s %s\n'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$slug&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;wp plugin get &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$slug&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--field&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;version&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$json&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.last_updated'&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="s1"&gt;' '&lt;/span&gt; &lt;span class="nt"&gt;-f1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$json&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.versions | keys | length'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rows that print &lt;code&gt;not-in-directory&lt;/code&gt; are the interesting ones. Those are your paid plugins, and they are exactly the ones the public tooling cannot help you with.&lt;/p&gt;

&lt;h2&gt;
  
  
  The integrity check does not cover the things you pay for
&lt;/h2&gt;

&lt;p&gt;This is the part that surprised me when I first hit it, and it is load-bearing for anyone reasoning about premium plugin risk.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp plugin verify-checksums &lt;span class="nt"&gt;--all&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That command compares the files on disk against the checksums published by wordpress.org. It works, it is fast, and it is genuinely useful for catching tampering. It also only works for plugins hosted in the directory. A premium plugin returns something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Warning: Could not retrieve the checksums for version 4.1.2 of plugin acme-pro, skipping.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no published source of truth to compare against, because the vendor distributes the zip themselves. It is documented behaviour on developer.wordpress.org and a long-standing open request in the wp-cli repo, not a bug in your setup.&lt;/p&gt;

&lt;p&gt;The implication is worth stating plainly. The one built-in integrity check WordPress ships does not cover premium plugins, which is precisely where vendor-delivered tampering has actually occurred. The ShapedPlugin compromise in June 2026, CVE-2026-10735 at CVSS 9.8, distributed backdoored builds to paying customers through the vendor's own update system. As reported by BleepingComputer and The Hacker News, and per data collected by Defiant, the backdoor went into the Pro builds on 21 May and the first customer reports arrived on 10 June, so roughly twenty days passed with the malicious release being served as a legitimate update. Only the paid tiers were affected.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;wp core verify-checksums&lt;/code&gt; is the equivalent for core and does work, so run it anyway:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp core verify-checksums
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Diff what a release actually changed
&lt;/h2&gt;

&lt;p&gt;For premium plugins, the only reliable record of what you bought is the code itself. Keep the plugin directory in git and every update becomes a reviewable diff.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;wp-content/plugins
git init
&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'*/cache/\n*/logs/\n*.log\n'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; .gitignore
git add &lt;span class="nt"&gt;-A&lt;/span&gt;
git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"baseline &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then commit after each update round, on a schedule, with the version in the message:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# snapshot.sh - commit the plugin tree after an update round&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail
&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;wp plugin path&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

wp plugin list &lt;span class="nt"&gt;--fields&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;name,version &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;csv &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; .versions.csv
git add &lt;span class="nt"&gt;-A&lt;/span&gt;
git commit &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"plugins &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"no change"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
git &lt;span class="nt"&gt;--no-pager&lt;/span&gt; log &lt;span class="nt"&gt;-1&lt;/span&gt; &lt;span class="nt"&gt;--stat&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-40&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A year later, the renewal question has an exact answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# what did this vendor ship in the last 12 months, by volume and by file&lt;/span&gt;
git log &lt;span class="nt"&gt;--since&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'12 months ago'&lt;/span&gt; &lt;span class="nt"&gt;--oneline&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; acme-pro | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
git diff &lt;span class="nt"&gt;--stat&lt;/span&gt; &lt;span class="s2"&gt;"@{12 months ago}"&lt;/span&gt; HEAD &lt;span class="nt"&gt;--&lt;/span&gt; acme-pro
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the diff, not just the counter. A vendor shipping eleven releases that only bump a version constant and update a readme is not the same as a vendor shipping four releases that rewrite the request handling. I have had both, and only one of them was worth the renewal.&lt;/p&gt;

&lt;p&gt;Two things to keep in mind before you do this on a real server. The tree contains no secrets in a normal install, but check for any plugin writing licence keys into its own directory before you commit, and never push the repo anywhere public. And keep the repo out of the web root's reachable space, or at minimum confirm your server config rejects requests for &lt;code&gt;.git&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Put a number on it
&lt;/h2&gt;

&lt;p&gt;Once you have release cadence and diff volume, the pricing comparison stops being a feeling. This computes break-even against a one-time price and prints the cadence you measured alongside it, so you are not deciding on cost alone.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;#!/usr/bin/env python3
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;breakeven.py - compare annual vs one-time licensing across an inventory.

Usage: ./breakeven.py licences.csv
CSV columns: name,annual,onetime,kind,releases_12mo
  kind = detection | configuration
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;csv&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;

&lt;span class="n"&gt;HORIZON&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;  &lt;span class="c1"&gt;# years to model
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;csv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DictReader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;plugin&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="si"&gt;}{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;kind&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="si"&gt;}{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;rel/yr&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="si"&gt;}{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;annual&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="si"&gt;}{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;once&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="si"&gt;}{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;break-even&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;total_annual&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;annual&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;annual&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;once&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;onetime&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;total_annual&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;annual&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;annual&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;once&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;be&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;n/a&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;years&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;once&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;annual&lt;/span&gt;
            &lt;span class="n"&gt;be&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;years&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; yr&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;years&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;HORIZON&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;HORIZON&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; yr&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="si"&gt;}{&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;kind&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="si"&gt;}{&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;releases_12mo&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="si"&gt;}{&lt;/span&gt;&lt;span class="n"&gt;annual&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mf"&gt;9.0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}{&lt;/span&gt;&lt;span class="n"&gt;once&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mf"&gt;8.0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}{&lt;/span&gt;&lt;span class="n"&gt;be&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;annual spend: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;total_annual&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/yr, &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;total_annual&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;HORIZON&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; over &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;HORIZON&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; years&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;stale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;
             &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kind&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;configuration&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;releases_12mo&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;stale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;configuration tooling with &amp;lt;=2 releases in 12 months (review these first):&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;stale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;  - &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;releases_12mo&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; releases, &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;annual&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/yr&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;licences.csv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;kind&lt;/code&gt; column is doing the real work, and it is the one judgement call in the whole exercise. Detection products are worth the most on the day after a new vulnerability lands, so their value arrives as a stream and a recurring fee matches it. Configuration products apply a setting once and then hold it, so the maintenance curve is flatter. A plugin in the second category shipping two releases a year is the row where recurring pricing is doing the least work for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why any of this matters more than a tighter budget
&lt;/h2&gt;

&lt;p&gt;Two findings from Patchstack's &lt;em&gt;State of WordPress Security in 2026&lt;/em&gt; changed how I read a renewal invoice.&lt;/p&gt;

&lt;p&gt;The first: of 1,983 valid vulnerability reports filed against premium or freemium WordPress products in 2025, 76% were rated exploitable in real-world attacks, and premium products carried three times as many Known Exploited Vulnerabilities as free ones. Paying buys you features, support and a vendor with a commercial reason to respond. It does not buy you a product with fewer flaws, and reading it as a quality signal is a mistake.&lt;/p&gt;

&lt;p&gt;The second: 46% of 2025 vulnerabilities had no patch available at disclosure, and the weighted median time to first exploitation was five hours. The report's own line on the first figure is that it shows why site owners cannot rely on plugin updates as a security measure. For roughly half of last year's disclosures, updating within a day was not slow, it was impossible.&lt;/p&gt;

&lt;p&gt;Both of those push the same way. What you can measure, budget and verify is your own configuration and your own inventory. What you cannot control is when the next disclosure lands or whether a fix exists when it does.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not tell you
&lt;/h2&gt;

&lt;p&gt;Release cadence is a proxy, not a verdict. A mature product with a small surface can legitimately ship twice a year, and diff volume rewards churn. Read the diffs before cancelling anything.&lt;/p&gt;

&lt;p&gt;It also says nothing about support quality, which for some teams is the entire product, and nothing about whether the plugin is doing its job. This is a purchasing audit, not a security audit.&lt;/p&gt;

&lt;h2&gt;
  
  
  One question
&lt;/h2&gt;

&lt;p&gt;For anyone running more than ten client sites: what do you check before renewing a plugin licence, other than whether it is still installed? I have asked a lot of agencies this and the honest answer is usually nothing.&lt;/p&gt;

&lt;p&gt;I run engineering at Squirrly and spend more time in access logs than I would choose to. The longer argument about which licensing model fits which kind of tool is here: &lt;a href="https://wpghost.com/lifetime/" rel="noopener noreferrer"&gt;https://wpghost.com/lifetime/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The integrity check you already have skips your paid extensions</title>
      <dc:creator>Calin V.</dc:creator>
      <pubDate>Sat, 22 Aug 2026 10:06:29 +0000</pubDate>
      <link>https://dev.to/cifi/the-integrity-check-you-already-have-skips-your-paid-extensions-21ff</link>
      <guid>https://dev.to/cifi/the-integrity-check-you-already-have-skips-your-paid-extensions-21ff</guid>
      <description>&lt;p&gt;WP-CLI ships a real integrity check. It compares installed plugin files against the checksums published by wordpress.org:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp plugin verify-checksums &lt;span class="nt"&gt;--all&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a blog, that is a decent baseline. On a store, run it and read the warnings rather than the success line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Warning: Could not retrieve the checksums for version 5.4.1 of plugin woocommerce-subscriptions, skipping.
Warning: Could not retrieve the checksums for version 1.15.6 of plugin woocommerce-bookings, skipping.
Warning: Could not retrieve the checksums for version 3.2.0 of plugin advanced-shipping-rates, skipping.
Success: Verified 12 of 34 plugins.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The skips are not a bug. Checksums exist only for plugins hosted in the wordpress.org directory. Premium and custom plugins have no published source of truth to compare against, which is documented behaviour and a long-standing open request in the wp-cli repo. &lt;code&gt;wp core verify-checksums&lt;/code&gt; works fine for core, because core publishes checksums.&lt;/p&gt;

&lt;p&gt;Now line that up with what a WooCommerce build is. Gateways, subscriptions, shipping logic, tax, currency, abandoned-cart recovery. Fifteen to forty extensions attached to the payment path, and nearly all of them paid. According to Patchstack's State of WordPress Security in 2026, 91% of the 11,334 vulnerabilities disclosed across the ecosystem in 2025 were in plugins rather than core, and of 1,983 valid reports against premium or freemium products, 76% were rated exploitable in real-world attacks, with premium products carrying three times as many known exploited vulnerabilities as free ones.&lt;/p&gt;

&lt;p&gt;The one integrity check WordPress gives you covers the third of your plugin list with the least exposure, and skips the two thirds with the most.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build the baseline the checksum command cannot
&lt;/h2&gt;

&lt;p&gt;If nobody publishes a manifest for your paid extensions, publish your own. Do it immediately after an update, from a state you have reason to trust, and store it off the box.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# baseline.sh - snapshot every plugin file hash after a known-good update&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;WP_ROOT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/var/www/store"&lt;/span&gt;
&lt;span class="nv"&gt;OUT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/opt/manifests/plugins-&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%Y%m%d-%H%M&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;.sha256"&lt;/span&gt;

find &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;WP_ROOT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/wp-content/plugins"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-type&lt;/span&gt; f &lt;span class="se"&gt;\(&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s1"&gt;'*.php'&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s1"&gt;'*.js'&lt;/span&gt; &lt;span class="se"&gt;\)&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-not&lt;/span&gt; &lt;span class="nt"&gt;-path&lt;/span&gt; &lt;span class="s1"&gt;'*/node_modules/*'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-print0&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; | xargs &lt;span class="nt"&gt;-0&lt;/span&gt; &lt;span class="nb"&gt;sha256sum&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;OUT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nb"&gt;ln&lt;/span&gt; &lt;span class="nt"&gt;-sfn&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;OUT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; /opt/manifests/plugins-latest.sha256
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"wrote &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;OUT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; (&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; &amp;lt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;OUT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; files)"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the check, which is the half that runs on a schedule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# drift.sh - fail loudly if a plugin file changed outside an update window&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-uo&lt;/span&gt; pipefail

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;sha256sum&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="nt"&gt;--quiet&lt;/span&gt; /opt/manifests/plugins-latest.sha256 2&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"PLUGIN FILE DRIFT on &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;hostname&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; at &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-Is&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi
&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"clean"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things make this worth the twenty minutes. It covers premium code, which is the part &lt;code&gt;verify-checksums&lt;/code&gt; cannot see. And it produces a dated artifact, which matters later, because "when did this file change" is the first question anyone asks and the hardest one to answer after the fact.&lt;/p&gt;

&lt;p&gt;One honest limit up front. File hashing catches changes on disk. It does not catch a compromise where no plugin file changes at all, which is a real shape: a plugin that renders a remote feed inside wp-admin can serve attacker-controlled content while every file on the server hashes clean. Treat drift detection as one signal, not the answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Watch what the checkout page actually loads
&lt;/h2&gt;

&lt;p&gt;A skimmer earns in the browser, so check the browser. The useful inventory is not "what plugins are installed" but "what script origins does my checkout page pull from, and has that set changed".&lt;/p&gt;

&lt;p&gt;Collect the current set from the rendered page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// paste in DevTools on /checkout/, in an incognito window, logged out&lt;/span&gt;
&lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;script[src]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a working store that list is short and boring. Your own origin, the payment gateway, maybe an analytics host. If a fourth origin you do not recognise is in there, you have found something without running a scanner.&lt;/p&gt;

&lt;p&gt;Once you know the real set, enforce it. Content-Security-Policy on the checkout route turns the inventory into a control, because a script injected from anywhere else stops executing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;/checkout/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Content-Security-Policy&lt;/span&gt; &lt;span class="s"&gt;"default-src&lt;/span&gt; &lt;span class="s"&gt;'self'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="kn"&gt;script-src&lt;/span&gt; &lt;span class="s"&gt;'self'&lt;/span&gt; &lt;span class="s"&gt;https://js.stripe.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="kn"&gt;frame-src&lt;/span&gt; &lt;span class="s"&gt;https://js.stripe.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="kn"&gt;connect-src&lt;/span&gt; &lt;span class="s"&gt;'self'&lt;/span&gt; &lt;span class="s"&gt;https://api.stripe.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="kn"&gt;object-src&lt;/span&gt; &lt;span class="s"&gt;'none'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="kn"&gt;base-uri&lt;/span&gt; &lt;span class="s"&gt;'self'"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ship it in &lt;code&gt;Content-Security-Policy-Report-Only&lt;/code&gt; with a &lt;code&gt;report-uri&lt;/code&gt; for a week first. A checkout is the worst page on the site to break silently, and page builders and tag managers will surface violations you did not predict.&lt;/p&gt;

&lt;h2&gt;
  
  
  Change what a scanner can map before it maps it
&lt;/h2&gt;

&lt;p&gt;The reconnaissance step is cheap for the attacker and it is the step you can actually take away. An automated crawler reads paths and version strings, matches them against a public vulnerability list, and only then decides your store is worth a request. Break that chain and a large share of traffic never reaches the exploit stage, because the target selection fails.&lt;/p&gt;

&lt;p&gt;Stop advertising versions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// mu-plugin: 000-no-fingerprints.php&lt;/span&gt;
&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'the_generator'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'__return_empty_string'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;remove_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'wp_head'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'wp_generator'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;remove_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'wp_head'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'rsd_link'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;remove_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'wp_head'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'wlwmanifest_link'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// drop the ?ver= query string that pins every asset to a release&lt;/span&gt;
&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'style_loader_src'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'store_strip_ver'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'script_loader_src'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'store_strip_ver'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;store_strip_ver&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$src&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$src&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;remove_query_arg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ver'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$src&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$src&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Return 404 on the endpoints a store has no reason to expose publicly, at the rewrite layer, before PHP starts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="c1"&gt;# probes that only ever precede an exploit attempt on a store&lt;/span&gt;
&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="p"&gt;~&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt; &lt;span class="s"&gt;^/(wp-config&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="s"&gt;.php&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="s"&gt;.(bak|old|save|swp)|&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="s"&gt;.env|&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="s"&gt;.git/)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kn"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;/xmlrpc.php&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kn"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="p"&gt;~&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt; &lt;span class="s"&gt;^/wp-content/(plugins|themes)/[^/]+/readme&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="s"&gt;.txt&lt;/span&gt;$ &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kn"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="p"&gt;~&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt; &lt;span class="s"&gt;^/wp-content/uploads/.*&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="s"&gt;.(php|phtml|phar)&lt;/span&gt;$ &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kn"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Apache, same idea:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apache"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nl"&gt;IfModule&lt;/span&gt;&lt;span class="sr"&gt; mod_rewrite.c&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;
&lt;/span&gt;  &lt;span class="nc"&gt;RewriteEngine&lt;/span&gt; &lt;span class="ss"&gt;On&lt;/span&gt;
  &lt;span class="nc"&gt;RewriteRule&lt;/span&gt; ^wp-content/(plugins|themes)/[^/]+/readme\.txt$ - [R=404,L]
  &lt;span class="nc"&gt;RewriteRule&lt;/span&gt; ^wp-content/uploads/.*\.(php|phtml|phar)$ - [R=404,L]
  &lt;span class="nc"&gt;RewriteRule&lt;/span&gt; ^xmlrpc\.php$ - [R=404,L]
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nl"&gt;IfModule&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those &lt;code&gt;readme.txt&lt;/code&gt; files are the quiet one. Every plugin ships one, it names the plugin and its version in plain text, and a crawler reads it without authenticating. Reconfigure the response and version-matched targeting fails at the reconnaissance step.&lt;/p&gt;

&lt;p&gt;Two numbers that explain why this sits alongside patching rather than after it. Patchstack 2026 puts the weighted median time to first exploitation at five hours, and reports that 46% of 2025 vulnerabilities had no patch available at disclosure. Patch on the day, which is faster than most portfolios manage, and you are still late for a large share of disclosures, with nothing to apply for roughly half of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this stack does not do
&lt;/h2&gt;

&lt;p&gt;It does not detect and it does not clean. Everything above reduces what is reachable and tells you when something on disk moved. None of it finds malware already resident, and rewrite rules have nothing to say about a file that is already executing.&lt;/p&gt;

&lt;p&gt;If you think a skimmer is live in your checkout right now, the order is the other way round. Scan and clean with something built for detection, restore from a backup you have reason to trust, then close the path. Doing surface reduction first feels productive and accomplishes very little.&lt;/p&gt;

&lt;p&gt;Worth knowing before the cleanup, because it changes what "done" means: Patchstack 2026 records that the two top variants of the Monarx/Lock360 malware family accounted for 38% and 32% of all injected file detections in 2025, and that the family rewrites cleaned files from server memory as they are restored. Monarx's own conclusion, from roughly nine trillion file signals, is that signature-based delete-only security is no longer sufficient. A clean that leaves the entry path open frequently does not hold.&lt;/p&gt;

&lt;p&gt;And the part that is not a technical decision. If card data was skimmed, this is a cardholder-data event under PCI DSS rather than a cleanup job, and the disclosure obligations depend on how your gateway handles card data. That is worth establishing while nothing is wrong, not during.&lt;/p&gt;

&lt;h2&gt;
  
  
  One question
&lt;/h2&gt;

&lt;p&gt;For the people running stores in production: what is actually watching your paid extensions between updates? A hash manifest, a commercial file-integrity service, your host, or, if we are being honest about it, nothing at all?&lt;/p&gt;

&lt;p&gt;I run engineering at Squirrly and spend more time in access logs than I would choose to. The longer version of this argument, with the layer comparison as a table, is here: &lt;a href="https://wpghost.com/woocommerce-security/" rel="noopener noreferrer"&gt;https://wpghost.com/woocommerce-security/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>security</category>
      <category>php</category>
      <category>devops</category>
    </item>
    <item>
      <title>The pre-flight checklist for a WordPress passwordless rollout (so you don't lock yourself out)</title>
      <dc:creator>Calin V.</dc:creator>
      <pubDate>Fri, 07 Aug 2026 15:14:40 +0000</pubDate>
      <link>https://dev.to/cifi/the-pre-flight-checklist-for-a-wordpress-passwordless-rollout-so-you-dont-lock-yourself-out-8i</link>
      <guid>https://dev.to/cifi/the-pre-flight-checklist-for-a-wordpress-passwordless-rollout-so-you-dont-lock-yourself-out-8i</guid>
      <description>&lt;p&gt;You are about to turn on passwordless login for a WordPress site that other people use. The security case is well covered elsewhere. This post is the part that goes wrong: the endpoint you just exposed, the recovery path you have not decided yet, and the fifteen minutes of testing that separates a rollout from an incident.&lt;/p&gt;

&lt;p&gt;Order of operations first, because it is most of the value here:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Enable the factor on one test account. Nobody else.&lt;/li&gt;
&lt;li&gt;Enrol administrators.&lt;/li&gt;
&lt;li&gt;Decide and document the recovery path.&lt;/li&gt;
&lt;li&gt;Widen to everyone, with the old factor still working in parallel.
Turning a second factor on for every user at once, on a site where some fraction of members have a dead address on file, converts a support queue into a support incident. Everything below is the detail inside those four steps.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  1. Verify the origin binding actually works before you trust it
&lt;/h2&gt;

&lt;p&gt;The reason a passkey resists phishing is a string comparison the browser performs, not a policy you configure. When a credential is created, it is bound to the relying party ID, which is your registered domain. At authentication the browser compares the origin of the page requesting a signature against that RP ID and refuses to surface the credential on a mismatch.&lt;/p&gt;

&lt;p&gt;You can watch this in the console rather than taking it on faith. On your real login page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;publicKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;challenge&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRandomValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;))),&lt;/span&gt;
    &lt;span class="na"&gt;rpId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hostname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;userVerification&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;preferred&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60000&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;credential offered:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
 &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;refused:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the same snippet from a copy of the page served on any other hostname you control. The &lt;code&gt;rpId&lt;/code&gt; will not match the enrolled origin and the call fails before the user is prompted. That is the whole anti-phishing mechanism, and it is worth confirming once on your own stack.&lt;/p&gt;

&lt;p&gt;Two things to check while you are here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The site must be HTTPS.&lt;/strong&gt; WebAuthn is available only in a secure context. &lt;code&gt;localhost&lt;/code&gt; is exempt for development.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decide the RP ID deliberately if you use subdomains.&lt;/strong&gt; A credential registered with &lt;code&gt;rpId: "example.com"&lt;/code&gt; is usable on &lt;code&gt;shop.example.com&lt;/code&gt;. One registered with &lt;code&gt;rpId: "shop.example.com"&lt;/code&gt; is not usable on the apex. Getting this wrong on a multisite or a subdomain store is the most common enrolment bug I have seen.
Worth knowing what this control is worth: the Verizon 2025 Data Breach Investigations Report puts stolen credentials in 88% of Basic Web Application attacks, and credential stuffing at a median 19% of daily authentication attempts on affected applications. The origin check is aimed squarely at that traffic.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  2. You just added an unauthenticated endpoint. Rate limit it
&lt;/h2&gt;

&lt;p&gt;Magic link login means a route that accepts an email address from anyone and sends mail. That is a new unauthenticated write surface on your site, and it needs the same treatment as &lt;code&gt;/wp-login.php&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;nginx, limiting token requests to 1 per 20 seconds per IP with a small burst:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;limit_req_zone&lt;/span&gt; &lt;span class="nv"&gt;$binary_remote_addr&lt;/span&gt; &lt;span class="s"&gt;zone=magiclink:10m&lt;/span&gt; &lt;span class="s"&gt;rate=3r/m&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;/wp-json/your-namespace/v1/magic-link&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;limit_req&lt;/span&gt; &lt;span class="s"&gt;zone=magiclink&lt;/span&gt; &lt;span class="s"&gt;burst=2&lt;/span&gt; &lt;span class="s"&gt;nodelay&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;limit_req_status&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;try_files&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt;&lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="n"&gt;/index.php?&lt;/span&gt;&lt;span class="nv"&gt;$args&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Apache 2.4 with &lt;code&gt;mod_ratelimit&lt;/code&gt; is weaker for this; if you are on Apache, do it in PHP or at the edge. A minimal in-application throttle keyed on the submitted address rather than only the IP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'init'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="k"&gt;isset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$_POST&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'magic_link_email'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nv"&gt;$email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sanitize_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;wp_unslash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$_POST&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'magic_link_email'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$key&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ml_throttle_'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nb"&gt;md5&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$email&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'|'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$_SERVER&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'REMOTE_ADDR'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;get_transient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;status_header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nf"&gt;wp_die&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'Too many requests. Try again in a minute.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;set_transient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key on both the address and the IP. IP alone is trivially rotated; address alone lets one attacker lock a specific user out of their own login.&lt;/p&gt;

&lt;p&gt;Token rules worth enforcing regardless of implementation: single use, short lifetime (10 to 15 minutes is the usual compromise), invalidated on use and on a new issue, and never logged. A magic link is a bearer token. Whoever holds the URL is authenticated, which also means it should not end up in a &lt;code&gt;Referer&lt;/code&gt; header or an analytics query string.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Decide the recovery path before it is an incident
&lt;/h2&gt;

&lt;p&gt;Remove passwords and your account recovery flow becomes the weakest link in the chain. This is not a criticism of passkeys, it is arithmetic: the attacker moves to the cheapest remaining path.&lt;/p&gt;

&lt;p&gt;Pick one and write it into your runbook:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Recovery path&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;th&gt;Failure mode&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Second enrolled device&lt;/td&gt;
&lt;td&gt;Zero, if enforced at enrolment&lt;/td&gt;
&lt;td&gt;Users skip it unless the UI blocks them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hardware key in a drawer&lt;/td&gt;
&lt;td&gt;One key per admin&lt;/td&gt;
&lt;td&gt;Only works if someone can physically reach it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Admin-initiated re-enrolment&lt;/td&gt;
&lt;td&gt;Support time&lt;/td&gt;
&lt;td&gt;Only as strong as your identity check on the requester&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Emailed recovery link&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;Collapses account security into mailbox security&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If your recovery is an unverified email request, you have relocated the vulnerability rather than removed it.&lt;/p&gt;

&lt;p&gt;The one that catches teams out is the second row. A hardware key stored in an office nobody visits is not a recovery path, it is a story about one.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Get-back-in procedures, tested on staging first
&lt;/h2&gt;

&lt;p&gt;Test these before you need them, on a copy, with the site's real plugin set active.&lt;/p&gt;

&lt;p&gt;Deactivate the security plugin over SSH or WP-CLI, which restores the default login without touching core:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp plugin deactivate your-security-plugin &lt;span class="nt"&gt;--path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/var/www/example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No WP-CLI, no shell, only SFTP? Rename the plugin directory. WordPress deactivates a plugin whose folder has disappeared:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mv &lt;/span&gt;wp-content/plugins/your-security-plugin wp-content/plugins/your-security-plugin.off
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confirm you can create an emergency administrator from the CLI, and delete it the moment you are done:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp user create breakglass ops@example.com &lt;span class="nt"&gt;--role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;administrator &lt;span class="nt"&gt;--user_pass&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;openssl rand &lt;span class="nt"&gt;-base64&lt;/span&gt; 24&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="c"&gt;# ... regain access, fix the enrolment, then:&lt;/span&gt;
wp user delete breakglass &lt;span class="nt"&gt;--reassign&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run all three on staging. A recovery procedure you have never executed is a hypothesis.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The end-to-end test that catches the real breakages
&lt;/h2&gt;

&lt;p&gt;Second-factor prompts attach to the login form. They do not attach to checkout or to the block editor, so a normal customer purchase is unaffected. The failure mode is a custom or plugin-rendered login form that bypasses the standard hooks and therefore never shows the prompt.&lt;/p&gt;

&lt;p&gt;Check the response and the redirect chain directly rather than eyeballing the page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Does the login form still return 200 and set the expected test cookie?&lt;/span&gt;
curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code} %{redirect_url}\n'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://example.com/wp-login.php

&lt;span class="c"&gt;# Is the token endpoint actually throttling?&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;seq &lt;/span&gt;1 6&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}\n'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'magic_link_email=test@example.com'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    https://example.com/wp-json/your-namespace/v1/magic-link
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That loop should return a &lt;code&gt;429&lt;/code&gt; before it finishes. If it returns six &lt;code&gt;200&lt;/code&gt;s, your throttle is not wired up.&lt;/p&gt;

&lt;p&gt;Then walk one real account through, in this order: WooCommerce account login, any custom front-end login form, password reset, the second-factor prompt itself, and recovery. Five clicks, one account, before anyone else is enrolled.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not cover, and why that matters operationally
&lt;/h2&gt;

&lt;p&gt;Everything above hardens the credential path. It is the highest-value change most WordPress sites can make to their login layer, and it is also the entire scope of what it does.&lt;/p&gt;

&lt;p&gt;Three classes of attack are untouched by all of it, because the attacker never reaches the form:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pre-authentication plugin vulnerabilities.&lt;/strong&gt; Exploited by an unauthenticated request. There is no login event in that path to strengthen. Patchstack's State of WordPress Security in 2026 reports 11,334 new WordPress-ecosystem vulnerabilities disclosed in 2025, a 42% year-on-year rise, with 91% of them in plugins rather than core.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A compromised plugin update channel.&lt;/strong&gt; WordPress fetches, unpacks and executes vendor code on a schedule, with no authentication event anywhere in it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Client-side skimmers on checkout.&lt;/strong&gt; The attacker reads what the customer types. Your admin login was never involved.
The same Patchstack report records 20% of heavily-exploited vulnerabilities under active attack within six hours of disclosure, 45% within twenty-four hours, and 70% within seven days. Against a six-hour window, the control that matters is reducing what is reachable and patching fast, not strengthening a form the attacker skipped. Two surfaces, two controls, and the mistake is finishing one and believing you finished both.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before you enrol anyone, run the throttle loop in section 5 against your own token endpoint. I would like to know how many people get six &lt;code&gt;200&lt;/code&gt;s back, because that endpoint tends to ship without a limiter and nobody notices until the mail queue does.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>security</category>
      <category>webdev</category>
      <category>php</category>
    </item>
    <item>
      <title>Audit your robots.txt AI block list: a script that checks what your rules actually do</title>
      <dc:creator>Calin V.</dc:creator>
      <pubDate>Tue, 04 Aug 2026 10:33:39 +0000</pubDate>
      <link>https://dev.to/cifi/audit-your-robotstxt-ai-block-list-a-script-that-checks-what-your-rules-actually-do-5faf</link>
      <guid>https://dev.to/cifi/audit-your-robotstxt-ai-block-list-a-script-that-checks-what-your-rules-actually-do-5faf</guid>
      <description>&lt;p&gt;Your &lt;code&gt;robots.txt&lt;/code&gt; has an AI block in it. You pasted it at some point, it looked right, and you have not read it since. This post is a script that tells you what it is actually enforcing, because in my experience the answer is usually "not what you think" in one of four specific ways.&lt;/p&gt;

&lt;p&gt;The short version of the problem: every major vendor now runs &lt;strong&gt;three&lt;/strong&gt; bots, not one.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Vendor&lt;/th&gt;
&lt;th&gt;Training&lt;/th&gt;
&lt;th&gt;Search / retrieval&lt;/th&gt;
&lt;th&gt;User-triggered fetch&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Anthropic&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ClaudeBot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Claude-SearchBot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Claude-User&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GPTBot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OAI-SearchBot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ChatGPT-User&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Perplexity&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PerplexityBot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;(same bot indexes)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Perplexity-User&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Google-Extended&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Googlebot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Blocking the training bot costs you nothing. Blocking the search bot removes you from that vendor's answers. Most pasted block lists hit both, because they match on a vendor prefix instead of on individual tokens.&lt;/p&gt;

&lt;p&gt;And the promises differ per vendor, which is the part that decides your implementation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Anthropic&lt;/strong&gt; states all three of its crawlers honour robots.txt, including the user-triggered &lt;code&gt;Claude-User&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenAI&lt;/strong&gt; documents that robots.txt rules &lt;strong&gt;may not apply&lt;/strong&gt; to &lt;code&gt;ChatGPT-User&lt;/code&gt;.
So a robots.txt entry is a credible control across Anthropic's whole family, and is not sufficient for OpenAI's fetcher. Same three-tier shape, different enforceable surface.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  1. Dump what you are actually serving
&lt;/h2&gt;

&lt;p&gt;Not the file on disk. What the origin returns. On WordPress these differ constantly: with no physical &lt;code&gt;robots.txt&lt;/code&gt;, core generates a virtual one, so editing the file you think exists is a silent no-op.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;BASE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"https://example.com"&lt;/span&gt;

&lt;span class="c"&gt;# what the world sees&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BASE&lt;/span&gt;&lt;span class="s2"&gt;/robots.txt"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-iE&lt;/span&gt; &lt;span class="s1"&gt;'user-agent|disallow'&lt;/span&gt; | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'s/^/  /'&lt;/span&gt;

&lt;span class="c"&gt;# is it physical or virtual? a physical file usually carries an ETag / Last-Modified&lt;/span&gt;
curl &lt;span class="nt"&gt;-sI&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BASE&lt;/span&gt;&lt;span class="s2"&gt;/robots.txt"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-iE&lt;/span&gt; &lt;span class="s1"&gt;'etag|last-modified|x-cache|cf-cache-status'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your edits are not in that first output, stop here. Nothing else you configured is running either.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Flag deprecated tokens
&lt;/h2&gt;

&lt;p&gt;Anthropic retired &lt;code&gt;Claude-Web&lt;/code&gt; and &lt;code&gt;anthropic-ai&lt;/code&gt;. They are harmless to keep, but their presence dates the file: a rule set naming them was written in 2024 and has not been reread since, which means every other decision in it is also a 2024 answer.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BASE&lt;/span&gt;&lt;span class="s2"&gt;/robots.txt"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-iE&lt;/span&gt; &lt;span class="s1"&gt;'claude-web|anthropic-ai|cohere-ai|omgili'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"^^ deprecated tokens present - this file needs a full reread"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. Test enforcement per bot, in both directions
&lt;/h2&gt;

&lt;p&gt;This is the part that matters, and the assertion nobody writes is the second one: &lt;strong&gt;the search bot must still get a 200.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# ai-crawler-audit.sh - verify per-bot policy against a live origin&lt;/span&gt;
&lt;span class="nv"&gt;BASE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;1&lt;/span&gt;:?usage:&lt;span class="p"&gt; ai-crawler-audit.sh https&lt;/span&gt;://example.com&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# agent | expected: BLOCK or ALLOW&lt;/span&gt;
&lt;span class="nv"&gt;BOTS&lt;/span&gt;&lt;span class="o"&gt;=(&lt;/span&gt;
  &lt;span class="s2"&gt;"ClaudeBot|BLOCK"&lt;/span&gt;
  &lt;span class="s2"&gt;"Claude-SearchBot|ALLOW"&lt;/span&gt;
  &lt;span class="s2"&gt;"Claude-User|ALLOW"&lt;/span&gt;
  &lt;span class="s2"&gt;"GPTBot|BLOCK"&lt;/span&gt;
  &lt;span class="s2"&gt;"OAI-SearchBot|ALLOW"&lt;/span&gt;
  &lt;span class="s2"&gt;"ChatGPT-User|ALLOW"&lt;/span&gt;
  &lt;span class="s2"&gt;"PerplexityBot|BLOCK"&lt;/span&gt;
  &lt;span class="s2"&gt;"Google-Extended|BLOCK"&lt;/span&gt;
  &lt;span class="s2"&gt;"Googlebot|ALLOW"&lt;/span&gt;
&lt;span class="o"&gt;)&lt;/span&gt;

&lt;span class="nv"&gt;fail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;span class="k"&gt;for &lt;/span&gt;entry &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BOTS&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;ua&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;%%|*&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;want&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;##*|&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nv"&gt;code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}'&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ua&lt;/span&gt;&lt;span class="s2"&gt;/1.0"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BASE&lt;/span&gt;&lt;span class="s2"&gt;/"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$want&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in
    &lt;/span&gt;BLOCK:40[0-9]|ALLOW:200&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"ok"&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt;
    &lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"MISMATCH"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;fail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="p"&gt;;;&lt;/span&gt;
  &lt;span class="k"&gt;esac&lt;/span&gt;
  &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%-18s want=%-5s got=%-3s  %s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ua&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$want&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$status&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done
&lt;/span&gt;&lt;span class="nb"&gt;exit&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$fail&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Edit the &lt;code&gt;BOTS&lt;/code&gt; array to match the policy you actually want, then run it in CI or a cron. The point is that the expectations are declared in one place and checked, instead of living in someone's memory.&lt;/p&gt;

&lt;p&gt;Two failure modes it catches immediately:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ClaudeBot&lt;/code&gt; returns 200.&lt;/strong&gt; Your rule is not running. Usual causes: virtual robots.txt, a CDN answering in front of the origin rule, or a host that ignores &lt;code&gt;.htaccess&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Claude-SearchBot&lt;/code&gt; returns 403.&lt;/strong&gt; Your pattern is too broad. A regex of &lt;code&gt;Claude&lt;/code&gt; matches all three Anthropic tokens, so you refused training &lt;em&gt;and&lt;/em&gt; deleted yourself from Claude's answers in one line.
## 4. Fix the too-broad pattern&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Anchor on full tokens, one per bot you actually mean. The clever compact regex is how the over-block happens.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;apache&lt;/strong&gt; (&lt;code&gt;.htaccess&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apache"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nl"&gt;IfModule&lt;/span&gt;&lt;span class="sr"&gt; mod_rewrite.c&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;
&lt;/span&gt;  &lt;span class="nc"&gt;RewriteEngine&lt;/span&gt; &lt;span class="ss"&gt;On&lt;/span&gt;
  &lt;span class="c"&gt;# training crawlers only - retrieval bots deliberately absent&lt;/span&gt;
  &lt;span class="nc"&gt;RewriteCond&lt;/span&gt; %{HTTP_USER_AGENT} (ClaudeBot|GPTBot|PerplexityBot|CCBot) [NC]
  &lt;span class="nc"&gt;RewriteRule&lt;/span&gt; .* - [F,L]
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nl"&gt;IfModule&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;nginx&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;map&lt;/span&gt; &lt;span class="nv"&gt;$http_user_agent&lt;/span&gt; &lt;span class="nv"&gt;$ai_training_bot&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;default&lt;/span&gt;              &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;"~*ClaudeBot"&lt;/span&gt;        &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;"~*GPTBot"&lt;/span&gt;           &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;"~*PerplexityBot"&lt;/span&gt;    &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;"~*CCBot"&lt;/span&gt;            &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;if&lt;/span&gt; &lt;span class="s"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$ai_training_bot&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kn"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note what is not in either list: no &lt;code&gt;Claude-SearchBot&lt;/code&gt;, no &lt;code&gt;OAI-SearchBot&lt;/code&gt;, no &lt;code&gt;Googlebot&lt;/code&gt;. That omission is the policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Verify the caller is who it claims
&lt;/h2&gt;

&lt;p&gt;The user-agent string is public and forgeable, so treat it as a claim rather than an identity. Anthropic and OpenAI both publish crawler IP ranges, which makes the claim checkable: a request presenting as &lt;code&gt;ClaudeBot&lt;/code&gt; from an address outside the published set is provably lying.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# pull the IPs your log attributed to a given bot, then check them against&lt;/span&gt;
&lt;span class="c"&gt;# the vendor's published prefixes before trusting the label&lt;/span&gt;
&lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'/ClaudeBot/ {print $1}'&lt;/span&gt; /var/log/nginx/access.log | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-50&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confirm the current endpoint and JSON schema for each vendor's published ranges before wiring this into automation. Both vendors have changed the format at least once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where a plugin earns its place, and where it does not
&lt;/h2&gt;

&lt;p&gt;Everything above is a hand-maintained rule set, and hand-maintained is a real option: a competent sysadmin's nginx &lt;code&gt;map&lt;/code&gt; and a plugin-managed rule produce the identical 403. The difference is upkeep. Tokens get added, renamed, and deprecated, syntax differs across Apache and nginx, and a botched &lt;code&gt;.htaccess&lt;/code&gt; edit takes the whole site down rather than one rule.&lt;/p&gt;

&lt;p&gt;That maintenance gap is the honest case for something like &lt;a href="https://wordpress.org/plugins/hide-my-wp/" rel="noopener noreferrer"&gt;WP Ghost&lt;/a&gt;, which keeps a list of 30+ AI and scraper user agents with per-bot toggles and applies them at a server-level firewall ahead of PHP, or for equivalent edge-managed bot rules if you already run everything through a CDN. Pick based on how many sites you maintain and who is responsible when a vendor adds a fourth bot.&lt;/p&gt;

&lt;p&gt;What none of them do, including that one: unmask a scraper that lies. A user-agent policy governs the population that identifies itself. A residential proxy pool presenting a Chrome string is a rate-limiting and traffic-shaping problem with a different toolkit, and any tool advertising "blocks bad bots" as one checkbox is collapsing two populations that need different answers. Consent enforcement and extraction defence are separate projects; the audit above is squarely the first one.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>security</category>
      <category>webdev</category>
      <category>devops</category>
    </item>
    <item>
      <title>WordPress path security: return 404 before PHP runs (Apache + nginx)</title>
      <dc:creator>Calin V.</dc:creator>
      <pubDate>Wed, 22 Jul 2026 16:32:34 +0000</pubDate>
      <link>https://dev.to/cifi/wordpress-path-security-return-404-before-php-runs-apache-nginx-30ba</link>
      <guid>https://dev.to/cifi/wordpress-path-security-return-404-before-php-runs-apache-nginx-30ba</guid>
      <description>&lt;p&gt;Every default WordPress install answers the same handful of requests at the same paths. That's what lets one script attack hundreds of thousands of sites: &lt;code&gt;/wp-login.php&lt;/code&gt;, &lt;code&gt;/wp-admin/&lt;/code&gt;, &lt;code&gt;/?author=1&lt;/code&gt;, &lt;code&gt;/xmlrpc.php&lt;/code&gt;, &lt;code&gt;/wp-content/uploads/&lt;/code&gt;. Same coordinates everywhere.&lt;/p&gt;

&lt;p&gt;Path security is moving or rejecting those predictable paths at the &lt;strong&gt;rewrite layer&lt;/strong&gt;, so the request returns 404 &lt;em&gt;before&lt;/em&gt; PHP loads. Not a PHP firewall that boots WordPress and then decides to block. The server's rewrite rules (Apache &lt;code&gt;mod_rewrite&lt;/code&gt;, nginx &lt;code&gt;location&lt;/code&gt; blocks) answer first.&lt;/p&gt;

&lt;p&gt;It matters because the targeting is automated and fast. Per Patchstack's &lt;em&gt;State of WordPress Security in 2026&lt;/em&gt;, 91% of the 11,334 vulns disclosed in 2025 were in plugins, and heavily-exploited ones are hit within 6h (20%), 24h (45%), and 7 days (70%) of disclosure. You reject the recon request, the exploit request behind it never arrives.&lt;/p&gt;

&lt;p&gt;Two rules before touching anything: &lt;strong&gt;do the login-path move last&lt;/strong&gt;, and &lt;strong&gt;verify every change from outside&lt;/strong&gt; with &lt;code&gt;curl&lt;/code&gt;, not from a settings screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  0. Watch what a bot sees
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;p &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"wp-login.php"&lt;/span&gt; &lt;span class="s2"&gt;"wp-admin/"&lt;/span&gt; &lt;span class="s2"&gt;"?author=1"&lt;/span&gt; &lt;span class="s2"&gt;"xmlrpc.php"&lt;/span&gt; &lt;span class="s2"&gt;"wp-content/uploads/"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}"&lt;/span&gt; &lt;span class="s2"&gt;"https://example.com/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;p&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s2"&gt;"%-22s -&amp;gt; %s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$p&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Whatever returns 200/301/302 is a coordinate the script already has. The goal below is to turn those into 404/403.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Kill directory listing (zero lockout risk, do it first)
&lt;/h2&gt;

&lt;p&gt;If &lt;code&gt;/wp-content/uploads/&lt;/code&gt; has no index file, the server lists every file in it: PDFs, invoices, a stray DB export. Close it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apache"&gt;&lt;code&gt;&lt;span class="c"&gt;# .htaccess (Apache / LiteSpeed)&lt;/span&gt;
&lt;span class="nc"&gt;Options&lt;/span&gt; -Indexes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="c1"&gt;# nginx server/location block&lt;/span&gt;
&lt;span class="k"&gt;autoindex&lt;/span&gt; &lt;span class="no"&gt;off&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"https://example.com/wp-content/uploads/"&lt;/span&gt;  &lt;span class="c"&gt;# want 403, not a file list&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. Reject path traversal and sensitive-file requests
&lt;/h2&gt;

&lt;p&gt;Path traversal (CWE-22) uses &lt;code&gt;../&lt;/code&gt; sequences to climb out of a plugin's directory and read files like &lt;code&gt;wp-config.php&lt;/code&gt;. You can't patch someone else's plugin from here, but you can reject the malformed request at the edge of your own server so it never reaches the plugin's PHP.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apache"&gt;&lt;code&gt;&lt;span class="c"&gt;# .htaccess: block encoded/literal traversal and dotfile probes&lt;/span&gt;
&lt;span class="nc"&gt;RewriteEngine&lt;/span&gt; &lt;span class="ss"&gt;On&lt;/span&gt;
&lt;span class="nc"&gt;RewriteCond&lt;/span&gt; %{QUERY_STRING} (\.\./|\.\.%2f|%2e%2e) [NC]
&lt;span class="nc"&gt;RewriteRule&lt;/span&gt; ^ - [F]

&lt;span class="c"&gt;# deny direct hits on sensitive files&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nl"&gt;FilesMatch&lt;/span&gt;&lt;span class="sr"&gt; "^(wp-config\.php|readme\.html|license\.txt|xmlrpc\.php)$"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;
&lt;/span&gt;  &lt;span class="nc"&gt;Require&lt;/span&gt; &lt;span class="ss"&gt;all&lt;/span&gt; denied
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nl"&gt;FilesMatch&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="p"&gt;~&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt; &lt;span class="s"&gt;(&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="s"&gt;./|&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="s"&gt;.%2f|%2e%2e)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kn"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;/xmlrpc.php&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kn"&gt;deny&lt;/span&gt; &lt;span class="s"&gt;all&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;/readme.html&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kn"&gt;deny&lt;/span&gt; &lt;span class="s"&gt;all&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is surface reduction, not a patch. It buys the window until the plugin update lands. Update anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Suppress full path disclosure
&lt;/h2&gt;

&lt;p&gt;An error that prints &lt;code&gt;/home/user/public_html/wp-content/...&lt;/code&gt; hands a traversal attempt its map. Turn off public debug output so error paths never render.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// wp-config.php&lt;/span&gt;
&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'WP_DEBUG'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'WP_DEBUG_DISPLAY'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="nb"&gt;ini_set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'display_errors'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And make sure the log itself isn't served:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apache"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nl"&gt;Files&lt;/span&gt;&lt;span class="sr"&gt; debug.log&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;
&lt;/span&gt;  &lt;span class="nc"&gt;Require&lt;/span&gt; &lt;span class="ss"&gt;all&lt;/span&gt; denied
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nl"&gt;Files&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. Close author enumeration
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;/?author=1&lt;/code&gt; redirects to the author slug (a real username), and the REST API lists users. A known username makes credential stuffing easier, and 88% of web-app attacks used stolen credentials (Verizon 2025 DBIR).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apache"&gt;&lt;code&gt;&lt;span class="c"&gt;# .htaccess&lt;/span&gt;
&lt;span class="nc"&gt;RewriteCond&lt;/span&gt; %{QUERY_STRING} (^|&amp;amp;)author=([0-9]+) [NC]
&lt;span class="nc"&gt;RewriteRule&lt;/span&gt; ^ - [F]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// close the REST users route for anonymous requests&lt;/span&gt;
&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'rest_endpoints'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;unset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'/wp/v2/users'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'/wp/v2/users/(?P&amp;lt;id&amp;gt;[\d]+)'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  5. Move the login path (LAST, with a rollback staged)
&lt;/h2&gt;

&lt;p&gt;This is the highest-impact change and the one most likely to lock you out, because plugins generate their own login links (WooCommerce account pages, membership flows, password-reset emails, builder previews). Miss one reference and you get a redirect loop.&lt;/p&gt;

&lt;p&gt;Stage the rollback &lt;strong&gt;before&lt;/strong&gt; you make the change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cp&lt;/span&gt; .htaccess .htaccess.bak
&lt;span class="c"&gt;# recovery if you lock yourself out:&lt;/span&gt;
&lt;span class="c"&gt;#   restore over SFTP/SSH, then flush caches&lt;/span&gt;
&lt;span class="c"&gt;#   mv .htaccess.bak .htaccess &amp;amp;&amp;amp; wp cache flush&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The mechanism: route your chosen path to the login handler and return 404 for the default.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apache"&gt;&lt;code&gt;&lt;span class="c"&gt;# .htaccess (illustrative; keep every generated login link in sync)&lt;/span&gt;
&lt;span class="nc"&gt;RewriteRule&lt;/span&gt; ^secret-door/?$ /wp-login.php [QSA,L]
&lt;span class="nc"&gt;RewriteCond&lt;/span&gt; %{REQUEST_URI} ^/(wp-login\.php|wp-admin)(/|$) [NC]
&lt;span class="nc"&gt;RewriteCond&lt;/span&gt; %{HTTP_COOKIE} !wordpress_logged_in [NC]
&lt;span class="nc"&gt;RewriteRule&lt;/span&gt; ^ - [R=404,L]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;nginx equivalent lives in the server config and needs a reload:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;/secret-door&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kn"&gt;try_files&lt;/span&gt; &lt;span class="n"&gt;/wp-login.php&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="p"&gt;~&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt; &lt;span class="s"&gt;^/(wp-login&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="s"&gt;.php|wp-admin)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;# allow logged-in sessions through your real path; 404 the rest&lt;/span&gt;
  &lt;span class="kn"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Doing this by hand means owning every hard-coded reference and re-testing on each plugin update, which is exactly why many people hand the rewrites to a login-path/hardening plugin instead. Either way, keep the old admin tab open until the new path is confirmed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Re-run the baseline
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"wp-login.php  -&amp;gt; %{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"https://example.com/wp-login.php"&lt;/span&gt;   &lt;span class="c"&gt;# 404&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"wp-admin/     -&amp;gt; %{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"https://example.com/wp-admin/"&lt;/span&gt;       &lt;span class="c"&gt;# 404/302 to 404&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"author=1      -&amp;gt; %{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"https://example.com/?author=1"&lt;/span&gt;        &lt;span class="c"&gt;# 403&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"uploads/      -&amp;gt; %{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"https://example.com/wp-content/uploads/"&lt;/span&gt; &lt;span class="c"&gt;# 403&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"xmlrpc.php    -&amp;gt; %{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"https://example.com/xmlrpc.php"&lt;/span&gt;        &lt;span class="c"&gt;# 403&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  "Isn't this security through obscurity?"
&lt;/h2&gt;

&lt;p&gt;No. Obscurity leaves the resource in place and bets nobody looks hard enough. A 404 at the rewrite layer means the request never reaches code that can act on it: the login handler doesn't run, the plugin endpoint doesn't load. The automated chain depends on predictable coordinates, so removing the coordinates breaks it at step one. That's deleting attack surface, not covering it.&lt;/p&gt;

&lt;p&gt;And it doesn't replace a scanner. Path security breaks recon; it doesn't disinfect an infected site. If the baseline in step 0 already looked wrong, clean first, then harden.&lt;/p&gt;

&lt;p&gt;On nginx-managed hosting, how do you handle the config reload for path rules without shell access — host automation, a plugin that writes the vhost, or something else? Curious what's actually working for people on SiteGround/Kinsta-style setups.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>security</category>
      <category>devops</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Prevent WordPress hacks by reading your own access log</title>
      <dc:creator>Calin V.</dc:creator>
      <pubDate>Wed, 08 Jul 2026 09:23:10 +0000</pubDate>
      <link>https://dev.to/cifi/prevent-wordpress-hacks-by-reading-your-own-access-log-1e9k</link>
      <guid>https://dev.to/cifi/prevent-wordpress-hacks-by-reading-your-own-access-log-1e9k</guid>
      <description>&lt;p&gt;WordPress hack prevention is not a product you install after a breach. It is the boring work you do before one, and almost all of it is a reaction to traffic that is already hitting your site right now. Open your access log and you can watch the first stage of nearly every WordPress compromise happen in real time: a bot confirming you run WordPress, listing your plugins, and testing your login. This post reads that log line by line, then breaks the attack chain stage by stage with configuration you already control.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  The three-stage chain, and why the first stage is the one to break
&lt;/h2&gt;

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

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

&lt;h2&gt;
  
  
  Read the reconnaissance in your access log
&lt;/h2&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Top probed WordPress paths&lt;/span&gt;
&lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $7}'&lt;/span&gt; /var/log/nginx/access.log &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-Ei&lt;/span&gt; &lt;span class="s1"&gt;'wp-login|xmlrpc|wp-admin|author=|wp-json/wp/v2/users|wp-content/plugins'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-20&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Top IPs hammering the login form (failed attempts)&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'POST /wp-login.php'&lt;/span&gt; /var/log/nginx/access.log &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'$9 == 200 {print $1}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;h2&gt;
  
  
  Stage one: make reconnaissance return nothing
&lt;/h2&gt;

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

&lt;p&gt;The lowest-risk wins first. Reject author enumeration and close the REST user list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apache"&gt;&lt;code&gt;&lt;span class="c"&gt;# .htaccess — reject /?author=N enumeration&lt;/span&gt;
&lt;span class="nc"&gt;RewriteEngine&lt;/span&gt; &lt;span class="ss"&gt;On&lt;/span&gt;
&lt;span class="nc"&gt;RewriteCond&lt;/span&gt; %{QUERY_STRING} (^|&amp;amp;)author=([0-9]+) [NC]
&lt;span class="nc"&gt;RewriteRule&lt;/span&gt; ^ - [F]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// mu-plugin: drop the REST users endpoints for anonymous requests&lt;/span&gt;
&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'rest_endpoints'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$endpoints&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;unset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$endpoints&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'/wp/v2/users'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="k"&gt;unset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$endpoints&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'/wp/v2/users/(?P&amp;lt;id&amp;gt;[\d]+)'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$endpoints&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;h2&gt;
  
  
  Stage two: break credential exploitation
&lt;/h2&gt;

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

&lt;p&gt;Rate-limit the login at the server layer so the flood never reaches PHP. In nginx:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="c1"&gt;# http { } block&lt;/span&gt;
&lt;span class="k"&gt;limit_req_zone&lt;/span&gt; &lt;span class="nv"&gt;$binary_remote_addr&lt;/span&gt; &lt;span class="s"&gt;zone=wplogin:10m&lt;/span&gt; &lt;span class="s"&gt;rate=20r/m&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;# server { } block&lt;/span&gt;
&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;/wp-login.php&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;limit_req&lt;/span&gt; &lt;span class="s"&gt;zone=wplogin&lt;/span&gt; &lt;span class="s"&gt;burst=5&lt;/span&gt; &lt;span class="s"&gt;nodelay&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;include&lt;/span&gt; &lt;span class="s"&gt;fastcgi_params&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;fastcgi_pass&lt;/span&gt; &lt;span class="s"&gt;unix:/run/php/php-fpm.sock&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then jail the repeat offenders with fail2ban so they stop consuming any resources at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/fail2ban/filter.d/wordpress-auth.conf
&lt;/span&gt;&lt;span class="nn"&gt;[Definition]&lt;/span&gt;
&lt;span class="py"&gt;failregex&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;^&amp;lt;HOST&amp;gt; .* "POST /wp-login&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s"&gt;php.*" 200&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/fail2ban/jail.d/wordpress.conf
&lt;/span&gt;&lt;span class="nn"&gt;[wordpress-auth]&lt;/span&gt;
&lt;span class="py"&gt;enabled&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;port&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;http,https&lt;/span&gt;
&lt;span class="py"&gt;filter&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;wordpress-auth&lt;/span&gt;
&lt;span class="py"&gt;logpath&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;/var/log/nginx/access.log&lt;/span&gt;
&lt;span class="py"&gt;maxretry&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;5&lt;/span&gt;
&lt;span class="py"&gt;findtime&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;600&lt;/span&gt;
&lt;span class="py"&gt;bantime&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;3600&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;h2&gt;
  
  
  Stage three: make persistence hard, and detect what slips through
&lt;/h2&gt;

&lt;p&gt;If exploitation still lands, the attacker wants to stay. Two config lines remove the easiest persistence routes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// wp-config.php&lt;/span&gt;
&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DISALLOW_FILE_EDIT'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// no theme/plugin editor in the dashboard&lt;/span&gt;
&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DISALLOW_FILE_MODS'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// no plugin/theme install or update from the dashboard&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;The uploads directory is the classic backdoor drop. It should never execute PHP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="p"&gt;~&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt; &lt;span class="n"&gt;/wp-content/uploads/.*\.php&lt;/span&gt;$ &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kn"&gt;deny&lt;/span&gt; &lt;span class="s"&gt;all&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apache"&gt;&lt;code&gt;&lt;span class="c"&gt;# wp-content/uploads/.htaccess&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nl"&gt;FilesMatch&lt;/span&gt;&lt;span class="sr"&gt; "\.php$"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;
&lt;/span&gt;  &lt;span class="nc"&gt;Require&lt;/span&gt; &lt;span class="ss"&gt;all&lt;/span&gt; denied
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nl"&gt;FilesMatch&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a cron that flags PHP where PHP should not be turns persistence into something you notice in hours, not weeks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Alert on unexpected PHP under uploads&lt;/span&gt;
find wp-content/uploads &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s1"&gt;'*.php'&lt;/span&gt; &lt;span class="nt"&gt;-type&lt;/span&gt; f &lt;span class="nt"&gt;-printf&lt;/span&gt; &lt;span class="s1"&gt;'%p\n'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the seam where prevention hands off to scanning, and it is worth being honest about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest limit: prevention is not a scanner
&lt;/h2&gt;

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

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

&lt;h2&gt;
  
  
  A minimal prevention baseline
&lt;/h2&gt;

&lt;p&gt;If you do nothing else this week, do these five, in order, verifying after each:&lt;/p&gt;

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

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

</description>
      <category>wordpress</category>
      <category>security</category>
      <category>devops</category>
      <category>php</category>
    </item>
    <item>
      <title>Renaming wp-login isn't the same as making wp-admin disappear</title>
      <dc:creator>Calin V.</dc:creator>
      <pubDate>Tue, 09 Jun 2026 16:00:40 +0000</pubDate>
      <link>https://dev.to/cifi/renaming-wp-login-isnt-the-same-as-making-wp-admin-disappear-2gg8</link>
      <guid>https://dev.to/cifi/renaming-wp-login-isnt-the-same-as-making-wp-admin-disappear-2gg8</guid>
      <description>&lt;p&gt;"How do I hide wp-admin" is one of the most-searched WordPress security questions, and most answers give the same advice: install a plugin that renames your login URL. That advice isn't wrong. It's just answering a smaller question than the one being asked.&lt;/p&gt;

&lt;p&gt;Renaming &lt;code&gt;/wp-login.php&lt;/code&gt; to &lt;code&gt;/my-login&lt;/code&gt; moves the login form. It does not change what answers at the old path, what your plugin folders advertise, or what your home page tells a scanner about the stack underneath. If your only problem is the password-guessing bot hammering the default form, a renamer solves it. If your problem is "stop my site from being identified and targeted as WordPress," you've solved maybe a third of it.&lt;/p&gt;

&lt;p&gt;Here are the three leaks a login rename leaves open.&lt;/p&gt;

&lt;h2&gt;
  
  
  Leak 1: the old path still costs a full WordPress boot
&lt;/h2&gt;

&lt;p&gt;When a login-URL renamer "blocks" the default path, the request to &lt;code&gt;/wp-login.php&lt;/code&gt; still loads WordPress. PHP starts, the plugin stack initializes, and only then does the plugin decide to return a 404 to the logged-out visitor.&lt;/p&gt;

&lt;p&gt;The visitor sees a 404. Your server still did the work of booting WordPress to produce it.&lt;/p&gt;

&lt;p&gt;On a quiet site, nobody notices. On a site taking tens of thousands of probes a day, that's tens of thousands of full WordPress boots spent generating 404s. Your security dashboard's "attempts blocked" counter looks great. Your CPU graph disagrees.&lt;/p&gt;

&lt;p&gt;The architectural alternative is to reject the request at the rewrite layer, before PHP runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apache"&gt;&lt;code&gt;&lt;span class="c"&gt;# Apache .htaccess — reject the default login path at the server level&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nl"&gt;IfModule&lt;/span&gt;&lt;span class="sr"&gt; mod_rewrite.c&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;
&lt;/span&gt;  &lt;span class="nc"&gt;RewriteEngine&lt;/span&gt; &lt;span class="ss"&gt;On&lt;/span&gt;
  &lt;span class="nc"&gt;RewriteCond&lt;/span&gt; %{REQUEST_URI} ^/(wp-login\.php|wp-admin) [NC]
  &lt;span class="nc"&gt;RewriteCond&lt;/span&gt; %{HTTP_COOKIE} !wordpress_logged_in [NC]
  &lt;span class="nc"&gt;RewriteRule&lt;/span&gt; .* - [R=404,L]
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nl"&gt;IfModule&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="c1"&gt;# nginx — same idea, requires a config reload after change&lt;/span&gt;
&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="p"&gt;~&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt; &lt;span class="s"&gt;^/(wp-login&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="s"&gt;.php|wp-admin)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;if&lt;/span&gt; &lt;span class="s"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$http_cookie&lt;/span&gt; &lt;span class="s"&gt;!~*&lt;/span&gt; &lt;span class="s"&gt;"wordpress_logged_in")&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kn"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The probe to the old path returns 404 from the server, WordPress never loads, and the request costs almost nothing. Same 404 the visitor sees, very different cost on your side, and anything that would have exploited a PHP-level flaw never reaches PHP. (Hand-rolling this is fine on a single site; the edge cases — AJAX on the front end, REST callbacks, WooCommerce account pages — are why people reach for a maintained tool once they run more than one site.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Leak 2: your plugin and theme folders still announce the stack
&lt;/h2&gt;

&lt;p&gt;Change the login URL and a bot that can't find the form doesn't quit your site. It pivots. The next cheap move is to fingerprint what you run, because a known-vulnerable plugin is a better door than a guessed password.&lt;/p&gt;

&lt;p&gt;That fingerprinting reads paths your own HTML hands over:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/wp-content/plugins/woocommerce/
/wp-content/plugins/elementor/
/wp-content/themes/your-theme/style.css?ver=2.4.1
/wp-content/plugins/some-plugin/readme.txt   &amp;lt;-  version in plain text
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;None of that is behind the login. It's in the page source of your home page. Patchstack counted 11,334 WordPress vulnerabilities disclosed in 2025, and 91% of them live in plugins rather than core. A scanner that reads your plugin inventory off the page source matches it against that disclosure list in milliseconds. Renaming the login form does nothing here, because the inventory never leaked through the login.&lt;/p&gt;

&lt;p&gt;Closing this leak means relocating the plugin and theme paths and stripping the version strings, so the same scanner reads asset URLs that carry no &lt;code&gt;wp-content&lt;/code&gt;, no plugin slug, and no version to match.&lt;/p&gt;

&lt;h2&gt;
  
  
  Leak 3: REST, AJAX, and the generator tag keep talking
&lt;/h2&gt;

&lt;p&gt;Even with the form moved and the plugin paths changed, WordPress has a few other mouths:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"generator"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"WordPress 6.x"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"https://api.w.org/"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://example.com/wp-json/"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;wp-json&lt;/code&gt; REST API answers at its canonical route and will, by default, enumerate usernames through &lt;code&gt;/wp-json/wp/v2/users&lt;/code&gt;. &lt;code&gt;admin-ajax.php&lt;/code&gt; sits at its default path. CMS detectors like Wappalyzer and BuiltWith key on exactly these signals. A login-URL renamer touches none of them, which is why a site with a renamed login but a default REST route still reads as unambiguous WordPress.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Capability&lt;/th&gt;
&lt;th&gt;Login-URL renamer (e.g. WPS Hide Login)&lt;/th&gt;
&lt;th&gt;Path + fingerprint layer&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Changes the login slug&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Old path rejected before PHP loads&lt;/td&gt;
&lt;td&gt;No (WordPress boots, then 404s)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Relocates wp-content / plugin / theme paths&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Removes generator, &lt;code&gt;?ver=&lt;/code&gt;, RSD from output&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Restricts wp-json username enumeration&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Footprint&lt;/td&gt;
&lt;td&gt;Very light, single-purpose&lt;/td&gt;
&lt;td&gt;Heavier, broad config&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Free tier&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A renamer wins the footprint row honestly. All-in-One Security sits in between: it renames the login URL and adds a &lt;code&gt;.htaccess&lt;/code&gt; firewall, but does less with the broader paths and the HTML fingerprint. None of this makes a renamer "bad." It makes it a different size of tool than the question usually wants.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to test what you actually changed
&lt;/h2&gt;

&lt;p&gt;One mistake to avoid: don't check your own site with Wappalyzer or BuiltWith while logged into wp-admin. The admin screens emit WordPress signals you can't strip, so you'll always see "WordPress" and conclude nothing worked. Test in a clean incognito window, logged out, and check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Generator tag and REST link still present?&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://example.com/ | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-iE&lt;/span&gt; &lt;span class="s1"&gt;'generator|api.w.org|/wp-json'&lt;/span&gt;

&lt;span class="c"&gt;# Default login path still booting WordPress instead of a server 404?&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; https://example.com/wp-login.php

&lt;span class="c"&gt;# Username enumeration via REST still open?&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://example.com/wp-json/wp/v2/users
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the first command returns matches, your fingerprint is still readable. If the second returns 200 (or a slow 404), PHP is still booting for that path. If the third returns a user list, enumeration is open. A login rename leaves all three exactly as they were.&lt;/p&gt;

&lt;p&gt;Renaming the form is a reasonable first move. Just measure it for what it is: one URL moved, not a stack reconfigured.&lt;/p&gt;

&lt;p&gt;What does your current setup return for those three curl checks — and were you surprised?&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>security</category>
      <category>php</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Wordfence vs WP Ghost: a layer-by-layer comparison</title>
      <dc:creator>Calin V.</dc:creator>
      <pubDate>Sat, 06 Jun 2026 14:46:17 +0000</pubDate>
      <link>https://dev.to/cifi/wordfence-vs-wp-ghost-a-layer-by-layer-comparison-4aio</link>
      <guid>https://dev.to/cifi/wordfence-vs-wp-ghost-a-layer-by-layer-comparison-4aio</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;I run engineering at Squirrly, where we operate a portfolio of WordPress sites. The "Wordfence or WP Ghost?" question comes up constantly, and it's framed wrong almost every time. They're not alternatives. They guard different layers. Here's the version with config examples instead of a marketing table.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The one-paragraph answer
&lt;/h2&gt;

&lt;p&gt;Wordfence is a scanner plus an in-application firewall. It runs as PHP inside WordPress, inspects requests after they've reached the application, and scans files on disk for known-bad signatures. WP Ghost works one layer earlier: it changes default paths and rejects malicious request patterns at the rewrite layer, before PHP loads. Detection and cleanup on one side, attack-surface reduction on the other. On most production sites you want both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the request actually goes
&lt;/h2&gt;

&lt;p&gt;A request to your WordPress site passes through a stack of layers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;client
  → network layer        (Cloudflare / host firewall: traffic shaping, DDoS)
  → server layer         (.htaccess / nginx rewrite rules: routing decisions)   ← WP Ghost
  → application layer     (WordPress boots, plugins init, endpoint firewall)     ← Wordfence
  → file system          (on-disk files; malware scanner reads here)            ← Wordfence
  → backups              (UpdraftPlus / host snapshots: recovery)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The position is the whole point. Wordfence's endpoint firewall is PHP code, so by the time it inspects a request, WordPress has already loaded. WP Ghost's 7G/8G rules are rewrite directives that execute before the PHP interpreter is invoked at all.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9954d7wuzrdaia1ekd8a.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9954d7wuzrdaia1ekd8a.png" alt="WordPress request-path layer diagram: network (Cloudflare), server/rewrite (WP Ghost), application and file system (Wordfence), backups (UpdraftPlus)" width="800" height="547"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What that difference costs (and saves)
&lt;/h2&gt;

&lt;p&gt;Consider a bot brute-forcing the default login path. With an in-PHP firewall alone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET /wp-login.php  →  Apache/nginx hands request to PHP
                   →  WordPress core loads
                   →  plugin stack initializes (including the firewall plugin)
                   →  firewall evaluates, rate-limits, returns 403
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every blocked probe still paid for a full WordPress boot. With a rewrite-layer path change, the default path simply isn't served:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET /wp-login.php  →  rewrite rule matches  →  404, PHP never invoked
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script gets a 404, doesn't adapt (these bots don't pivot to a renamed path), and moves to the next domain in its queue. On bot-heavy sites the net effect is a measurable load reduction, because the rejected requests never reach PHP.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3xkqeqhtro3fr8mgk6pu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3xkqeqhtro3fr8mgk6pu.png" alt="Side-by-side cost of a bot probe: in-PHP firewall loads WordPress then returns 403; rewrite-layer path change returns 404 before PHP runs" width="800" height="427"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The fingerprint problem
&lt;/h2&gt;

&lt;p&gt;Attack chains usually start with reconnaissance: confirm it's WordPress, enumerate plugins, match against a CVE list. WordPress advertises itself by default:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"generator"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"WordPress 6.x"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"https://api.w.org/"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://example.com/wp-json/"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"stylesheet"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://example.com/wp-content/plugins/some-plugin/style.css?ver=2.1"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each of those is a signal a classifier like Wappalyzer reads. WP Ghost removes them from output (generator tag, RSD header, version strings, &lt;code&gt;/wp-json/&lt;/code&gt; link) and changes the &lt;code&gt;/wp-content/&lt;/code&gt; and plugin paths in asset URLs. The classifier returns "unidentified CMS," and the plugin-enumeration step that feeds CVE targeting comes back empty. This matters in context: Patchstack's 2026 report counted 11,334 WordPress vulnerabilities disclosed in 2025 (a 42% YoY increase), 91% of them in plugins. Most of those exploits start by confirming the plugin is installed.&lt;/p&gt;

&lt;p&gt;Wordfence does not do any of this path-changing or fingerprint removal. That's not a knock; it's a different job.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Wordfence does that WP Ghost doesn't
&lt;/h2&gt;

&lt;p&gt;Be honest or don't write the comparison. Wordfence's malware scanner, file-integrity monitoring, live traffic view, and Premium threat-intel feed are genuinely strong. WP Ghost scans nothing. If a file on disk is already infected, or was planted before you hardened the site, WP Ghost won't find it. The scanner will. With WPMayor's reporting putting the disclosure-to-exploitation window around five hours, the Premium rule feed is a real asset on high-value sites.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying the prevention layer actually works
&lt;/h2&gt;

&lt;p&gt;After changing paths, confirm the default endpoints are gone and the new ones respond:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# default login should now 404&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; https://example.com/wp-login.php
&lt;span class="c"&gt;# expect: 404&lt;/span&gt;

&lt;span class="c"&gt;# fingerprint check: generator tag should be absent&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://example.com/ | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'name="generator"'&lt;/span&gt;
&lt;span class="c"&gt;# expect: no output&lt;/span&gt;

&lt;span class="c"&gt;# new login path should serve the form (replace with your configured path)&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; https://example.com/your-new-login/
&lt;span class="c"&gt;# expect: 200&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're on nginx, remember the rewrite changes need a config reload (&lt;code&gt;nginx -s reload&lt;/code&gt; or your host's equivalent). Apache and LiteSpeed pick up &lt;code&gt;.htaccess&lt;/code&gt; changes without a reload.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest overlap
&lt;/h2&gt;

&lt;p&gt;Both tools do 2FA, brute-force rate limiting, and basic hardening. Don't double-configure rate limiting; pick one. The one authentication edge worth calling out: WP Ghost ships passkey 2FA (FIDO2 / WebAuthn: Face ID, Touch ID, Windows Hello, hardware keys) in its free tier. Passkeys are phishing-resistant because the WebAuthn challenge is bound to the origin, so a phished credential can't be replayed against your domain.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack I'd actually run
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Cloudflare (free)         → network noise + DDoS
WP Ghost (free)           → path changes, fingerprint removal, 7G/8G rewrite firewall
Wordfence (free or Prem)  → malware scan, file integrity, live traffic
UpdraftPlus               → backups / recovery
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Roughly: WP Ghost lowers what reaches the application, Wordfence catches what gets through and what's already there, backups get you home if both fail.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;Wordfence detects and cleans. WP Ghost prevents and relocates. They sit on different layers and the feature rows barely overlap, so "vs" is the wrong framing. Run the scanner for eyes on disk, run the path-changer for a smaller front door.&lt;/p&gt;

&lt;p&gt;Question for the comments: if you run both, did you notice a server-load change after moving the login and admin paths off the defaults? I'm curious how consistent the drop is across different hosts.&lt;/p&gt;

&lt;p&gt;WP Ghost has a free version on &lt;a href="https://wordpress.org/plugins/hide-my-wp/" rel="noopener noreferrer"&gt;wordpress.org&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>security</category>
      <category>php</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
