<?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>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>
    <item>
      <title>43 WordPress Security Data Points That Should Change How You Build Sites in 2026</title>
      <dc:creator>Calin V.</dc:creator>
      <pubDate>Fri, 17 Apr 2026 05:24:49 +0000</pubDate>
      <link>https://dev.to/cifi/43-wordpress-security-data-points-that-should-change-how-you-build-sites-in-2026-fjl</link>
      <guid>https://dev.to/cifi/43-wordpress-security-data-points-that-should-change-how-you-build-sites-in-2026-fjl</guid>
      <description>&lt;p&gt;Every year, Patchstack publishes a whitepaper on the state of WordPress security. Every year, the numbers get worse. The &lt;a href="https://patchstack.com/whitepaper/state-of-wordpress-security-in-2026/" rel="noopener noreferrer"&gt;2026 edition&lt;/a&gt; dropped on February 25, and the headline numbers are hard to ignore.&lt;/p&gt;

&lt;p&gt;This post pulls together 43 verified data points from 18 original research sources into the picture you need if you build, manage, or host WordPress sites. No speculation, no fear marketing, just numbers and what they mean for your workflow.&lt;/p&gt;

&lt;p&gt;The full sourced version with inline citations lives on the &lt;a href="https://hidemywpghost.com/wordpress-security-statistics-2025-2026-43-verified-data-points/" rel="noopener noreferrer"&gt;WP Ghost research page&lt;/a&gt;. This is the developer-focused summary.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Four Numbers That Define WordPress Security Right Now
&lt;/h2&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%2Fv9py7jys1djzcfp8llwd.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%2Fv9py7jys1djzcfp8llwd.png" alt=" " width="780" height="467"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Before getting into the breakdown, these four stats frame everything that follows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;11,334&lt;/strong&gt; new WordPress vulnerabilities recorded in 2025, a 42% increase year-on-year&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;~13,000&lt;/strong&gt; WordPress sites hacked per day, roughly 4.7 million annually&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;5 hours&lt;/strong&gt;, the weighted median from vulnerability disclosure to mass exploitation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;87.8%&lt;/strong&gt; of WordPress-specific exploits bypass standard hosting firewalls&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you only remember four numbers from this post, those are the ones.&lt;/p&gt;




&lt;h2&gt;
  
  
  How Large Is the Attack Surface?
&lt;/h2&gt;

&lt;p&gt;WordPress powers 43.5% of all websites on the internet (Hostinger, 2026). That's not a niche CMS. That's nearly half the web running on the same stack, with the same default paths, the same directory structure, and the same plugin ecosystem.&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%2Fnie4itvziljq2pfa14d0.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%2Fnie4itvziljq2pfa14d0.png" alt=" " width="800" height="257"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;From an attacker's perspective, the economics are straightforward. Write one scanner, point it at the internet, and nearly every other site you hit is WordPress. The attacks don't need to be clever. They just need to be automated and fast.&lt;/p&gt;

&lt;p&gt;Wordfence, the most widely deployed WordPress security plugin, blocks 55 million exploit attempts and over 6.4 billion brute force attacks every single month across its network (TDW Digital, 2025). That's the permanent baseline, not a spike.&lt;/p&gt;

&lt;p&gt;Most of those attacks are not targeted. Bots scan millions of sites daily looking for the same fingerprints: a &lt;code&gt;/wp-login.php&lt;/code&gt; path, an exposed version number in a meta tag, a plugin signature in the page source. They are not interested in &lt;em&gt;your&lt;/em&gt; site specifically. They are interested in any site that looks like a standard WordPress installation.&lt;/p&gt;




&lt;h2&gt;
  
  
  11,334 Vulnerabilities: Where Are They Coming From?
&lt;/h2&gt;

&lt;p&gt;Patchstack confirmed a record year. Not close to a record. Definitively the worst year on record, by a wide margin.&lt;/p&gt;

&lt;p&gt;The growth over three years tells the story:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Year&lt;/th&gt;
&lt;th&gt;Vulnerabilities&lt;/th&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2023&lt;/td&gt;
&lt;td&gt;~5,900&lt;/td&gt;
&lt;td&gt;baseline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2024&lt;/td&gt;
&lt;td&gt;~7,966&lt;/td&gt;
&lt;td&gt;+35%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2025&lt;/td&gt;
&lt;td&gt;11,334&lt;/td&gt;
&lt;td&gt;+42%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And Q1 2026 is not slowing down. In the first week of January 2026 alone, 333 new vulnerabilities were disclosed, with 120 of them having no patch when they went public (WebHostMost, Mar 2026). The weekly average heading into 2026 is 250+ plugin vulnerabilities per week, roughly 36 per day.&lt;/p&gt;

&lt;h3&gt;
  
  
  It's Not Core
&lt;/h3&gt;

&lt;p&gt;The WordPress core team found exactly two vulnerabilities in all of 2025 (Patchstack 2026). Two.&lt;/p&gt;

&lt;p&gt;The problem is the plugin ecosystem:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;91% of vulnerabilities&lt;/strong&gt; come from plugins&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;6%&lt;/strong&gt; from themes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2%&lt;/strong&gt; from core (those two)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every plugin you install is a potential entry point. The average WordPress installation runs 20 to 30 plugins. The math is not complicated.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Severity Jump
&lt;/h3&gt;

&lt;p&gt;Highly exploitable vulnerabilities, the ones attackers weaponize at scale, increased by 113% year-on-year in 2025 (The Repository / Patchstack, Mar 2026). More high-severity CVEs were found in 2025 than in the previous two years combined.&lt;/p&gt;

&lt;h3&gt;
  
  
  AI-Generated Plugins Are Making It Worse
&lt;/h3&gt;

&lt;p&gt;Patchstack's 2026 report specifically calls out "vibe coding," where developers use LLMs to generate plugin code and ship it without being able to audit what the model wrote. When the person shipping the code can't review it for security problems, vulnerabilities go live silently. This trend is accelerating, not stabilizing.&lt;/p&gt;




&lt;h2&gt;
  
  
  The 5-Hour Problem
&lt;/h2&gt;

&lt;p&gt;Here is the number from Patchstack's 2026 report that fundamentally changes how you should think about WordPress security:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"The weighted median time to first mass exploitation was five hours."&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Patchstack, State of WordPress Security in 2026&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;Five hours from public disclosure to active mass exploitation.&lt;/p&gt;

&lt;p&gt;The traditional advice, "keep your plugins updated," assumes you have time to react. For heavily targeted vulnerabilities, you often don't.&lt;/p&gt;

&lt;p&gt;The exploitation timeline breaks down like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Window&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0-5 hours&lt;/td&gt;
&lt;td&gt;Median first mass exploitation attempt. Automated scanners watch disclosure feeds and deploy exploit code within hours.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Within 6 hours&lt;/td&gt;
&lt;td&gt;20% of top-targeted vulnerabilities are actively exploited.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Within 24 hours&lt;/td&gt;
&lt;td&gt;45%. By the time most site owners read about it in a newsletter, attacks have been running for a full day.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Within 7 days&lt;/td&gt;
&lt;td&gt;70%. Roughly when most people get around to clicking that update notification they've been ignoring.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And what makes the 5-hour window even more dangerous:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;46% of vulnerabilities had no patch available at the time of public disclosure&lt;/strong&gt; (WP Edition / Patchstack, Feb 2026). There is no update to apply. The vulnerability is public, documented, actively scanned for, and the plugin is still broken.&lt;/p&gt;

&lt;p&gt;Why? Because &lt;strong&gt;52% of plugin developers do not patch before disclosure&lt;/strong&gt; (Patchstack via Xictron, 2026). The researcher found the bug, reported it responsibly, waited the standard disclosure window, published, and the developer still hadn't shipped a fix.&lt;/p&gt;

&lt;p&gt;Patchstack says it plainly: "Regular plugin updates are the second line of defence, but as attackers weaponize new vulnerabilities within mere hours, this is not a viable defence."&lt;/p&gt;




&lt;h2&gt;
  
  
  Where Do Hackers Actually Get In?
&lt;/h2&gt;

&lt;p&gt;The vulnerability type distribution for 2025-2026:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Attack Type&lt;/th&gt;
&lt;th&gt;Share&lt;/th&gt;
&lt;th&gt;Auth Required?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cross-Site Scripting (XSS)&lt;/td&gt;
&lt;td&gt;Dominant (~35-39%)&lt;/td&gt;
&lt;td&gt;Often none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cross-Site Request Forgery (CSRF)&lt;/td&gt;
&lt;td&gt;~19%&lt;/td&gt;
&lt;td&gt;Existing session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local File Inclusion (LFI)&lt;/td&gt;
&lt;td&gt;~12.6%&lt;/td&gt;
&lt;td&gt;Often none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Broken Access Control&lt;/td&gt;
&lt;td&gt;~10.9%&lt;/td&gt;
&lt;td&gt;Low-privilege&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SQL Injection&lt;/td&gt;
&lt;td&gt;~7.2%&lt;/td&gt;
&lt;td&gt;Often none&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;XSS dominates because it's easy to introduce in plugin code and notoriously difficult to patch completely.&lt;/p&gt;

&lt;p&gt;The critical detail: &lt;strong&gt;57% of vulnerabilities in H1 2025 required no authentication whatsoever&lt;/strong&gt; (Patchstack Mid-Year 2025). No login needed. No password to steal. Just a vulnerable plugin, installed and active.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Attackers Leave Behind
&lt;/h3&gt;

&lt;p&gt;Sucuri's data on what's found on compromised WordPress sites (Sucuri via Hostinger, 2026):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;72.7%&lt;/strong&gt; contain active malware&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;69.6%&lt;/strong&gt; have unauthorized backdoors, persistent access the attacker can use later&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;46.7%&lt;/strong&gt; have SEO spam injected, hidden keyword-stuffed content that triggers Google penalties&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;8.1%&lt;/strong&gt; have phishing pages embedded within the site&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The 2026 Patchstack and Monarx report adds an important shift: attackers are now injecting code directly into legitimate WordPress core, plugin, and theme files rather than dropping standalone malicious files. The traditional "scan and delete" approach to malware removal misses this entirely (The Repository, Mar 2026).&lt;/p&gt;

&lt;h3&gt;
  
  
  The Login Page Problem
&lt;/h3&gt;

&lt;p&gt;In 2023, Wordfence blocked over &lt;strong&gt;100 billion credential stuffing attacks&lt;/strong&gt; from 74 million unique IP addresses. The vast majority targeted the standard &lt;code&gt;/wp-login.php&lt;/code&gt; and &lt;code&gt;/wp-admin&lt;/code&gt; paths. 81% of hacked WordPress sites involved weak or stolen passwords as a contributing factor (Sucuri and Wordfence via HowToWP).&lt;/p&gt;

&lt;p&gt;If your login page is at the default URL, you are absorbing every automated attack that knows where to look.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Hosting Firewalls Are Not Enough
&lt;/h2&gt;

&lt;p&gt;Managed WordPress hosting is expensive and comes with security messaging all over the pricing page. Those server-level firewalls are doing something, just not nearly enough.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;87.8% of WordPress-specific exploits bypass standard hosting defenses&lt;/strong&gt; (Patchstack via Xictron, 2026). Standard hosting defenses block only about 26% of WordPress-targeted attacks.&lt;/p&gt;

&lt;p&gt;The reason is structural. Network and server firewalls were designed to block broad categories of malicious traffic: DDoS floods, port scans, known malicious IPs. They were not designed to understand the application-specific semantics of a stored XSS vulnerability in version 3.4.1 of a specific WordPress plugin. That requires application-layer intelligence.&lt;/p&gt;

&lt;p&gt;Patchstack's 2026 conclusion: "In 2026, everybody needs deep visibility into what their websites are made of and put automated security measures in place to mitigate new security vulnerabilities in less than five hours."&lt;/p&gt;

&lt;h3&gt;
  
  
  The EU Cyber Resilience Act
&lt;/h3&gt;

&lt;p&gt;By September 2026, all plugin and theme developers distributing software to EU users must have vulnerability disclosure programs in place by law. Whether this will meaningfully reduce the 52% non-patch-before-disclosure rate remains to be seen (Patchstack 2025 Whitepaper).&lt;/p&gt;




&lt;h2&gt;
  
  
  What Does a WordPress Hack Actually Cost?
&lt;/h2&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%2Fp84ju970wwup9woxhw4n.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%2Fp84ju970wwup9woxhw4n.png" alt=" " width="800" height="367"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The headline figure: &lt;strong&gt;$14,500 average total recovery cost&lt;/strong&gt; for a small business (Colorlib, Xictron 2026).&lt;/p&gt;

&lt;p&gt;That includes malware removal, emergency developer time, downtime, lost revenue, and the months of SEO work required to undo injected spam links and Google manual penalties. Against approximately $8/month for active protection, the math is not close.&lt;/p&gt;

&lt;p&gt;The Melapress 2025 Security Survey found that 59.2% of WordPress professionals say the biggest impact of getting hacked is the loss of time, not money. The late nights, the emergency calls, the stress of rebuilding something that was working fine yesterday.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A real scenario:&lt;/strong&gt; A WooCommerce store gets flagged by Google on a Friday afternoon. Malware was redirecting the checkout page to a phishing site. By Monday morning, it's been three days. Google Search Console has a manual penalty notice. PayPal suspended the account. Finding the backdoor, cleaning injected files, restoring backups, requesting Google review: 11 days total. $4,200 in lost orders. $1,200 for emergency cleanup. $5,400 total. The security plugin subscription they had skipped: $96 per year.&lt;/p&gt;




&lt;h2&gt;
  
  
  Are WordPress Site Owners Actually Prepared?
&lt;/h2&gt;

&lt;p&gt;The Melapress WordPress Security Survey 2025 maps preparation against concern, and the gap is uncomfortable. Site owners rate their concern at 7.8 out of 10 on average. Two-thirds scored it 8 or higher.&lt;/p&gt;

&lt;p&gt;And yet:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Only &lt;strong&gt;27%&lt;/strong&gt; have a breach recovery plan. More than 73% have no documented response to "what do we do when this happens?"&lt;/li&gt;
&lt;li&gt;Only &lt;strong&gt;27%&lt;/strong&gt; implement team security training&lt;/li&gt;
&lt;li&gt;Among those who had experienced account compromises, &lt;strong&gt;30%&lt;/strong&gt; still hadn't implemented any user account security controls&lt;/li&gt;
&lt;li&gt;Only &lt;strong&gt;59%&lt;/strong&gt; use a WordPress activity log to detect suspicious behavior&lt;/li&gt;
&lt;li&gt;Web designers and developers, the professionals who build WordPress sites, are the least likely to use automatic updates, at 32% and 33% respectively&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's also an outsourcing gap: 31% of in-house managers have a recovery plan, but only 13% of those relying on third parties do (Melapress, Oct 2025).&lt;/p&gt;

&lt;p&gt;If you've outsourced security, ask your provider directly: what is the plan if we get hacked?&lt;/p&gt;




&lt;h2&gt;
  
  
  What Does the Data Say Actually Works?
&lt;/h2&gt;

&lt;p&gt;The genuinely good news: the overwhelming majority of WordPress hacks are preventable. OsomStudio's 2026 security analysis estimates that &lt;strong&gt;basic security hygiene stops more than 90% of attacks&lt;/strong&gt; (OsomStudio, 2026).&lt;/p&gt;

&lt;p&gt;Most attackers are opportunistic. When a site looks harder to target than average, they move on.&lt;/p&gt;

&lt;p&gt;Based on the attack data above, effective WordPress hack prevention works in three layers:&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 1: Disappear from Scanners
&lt;/h3&gt;

&lt;p&gt;Automated scanners identify WordPress installations by their fingerprints: version numbers in meta tags, the &lt;code&gt;/wp-login.php&lt;/code&gt; path, the &lt;code&gt;readme.html&lt;/code&gt; file in root, plugin signatures in source code.&lt;/p&gt;

&lt;p&gt;Remove those signals and mass-scanning tools can't confirm you are running WordPress. They move on. This is attack surface reduction as a first defense, and it works against every automated scanner that hasn't already fingerprinted your site.&lt;/p&gt;

&lt;p&gt;The data supports why this matters: 57% of vulnerabilities require zero authentication, meaning fingerprint detection is the first step in every automated attack chain.&lt;/p&gt;

&lt;p&gt;Tools that do this: &lt;a href="https://hidemywpghost.com/" rel="noopener noreferrer"&gt;WP Ghost&lt;/a&gt; (full path security, 115+ features in the free version), WPS Hide Login (login path only), or manual rewrite rules if you prefer to roll your own.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 2: Filter at the Application Layer
&lt;/h3&gt;

&lt;p&gt;What hosting firewalls miss (87.8% of it), an application-layer firewall catches:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;8G Firewall rules&lt;/strong&gt; for SQL injection, XSS, file inclusion, directory traversal&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Brute force protection + 2FA&lt;/strong&gt; to address the 81% of hacks involving credential attacks&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;XML-RPC and REST API restrictions&lt;/strong&gt; to close attack surfaces most sites don't need open&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Geo-blocking&lt;/strong&gt; to reduce automated attack volume from high-risk regions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Moving the login page&lt;/strong&gt; off &lt;code&gt;/wp-login.php&lt;/code&gt;, since most automated credential attacks target that path specifically&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Tools: WP Ghost (built-in 7G/8G firewall, 2FA with passkeys), Wordfence (endpoint firewall + scanner), Sucuri (cloud WAF), or a combination.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 3: Monitor and Detect
&lt;/h3&gt;

&lt;p&gt;Activity logging, file integrity monitoring, and anomaly alerts are the difference between catching a compromise in hour one and discovering it three months later when Google sends a penalty notice. Given that 69.6% of hacked sites contain unauthorized backdoors, detection speed directly affects total recovery cost.&lt;/p&gt;

&lt;p&gt;Tools: WP Ghost Security Threats Log (Premium), Wordfence live traffic, Sucuri audit log, or WP Activity Log.&lt;/p&gt;




&lt;h2&gt;
  
  
  Three Things You Can Do This Week
&lt;/h2&gt;

&lt;p&gt;If this wall of stats is overwhelming, start here:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Audit your plugins
&lt;/h3&gt;

&lt;p&gt;List every plugin on your site. For each one: is it actively maintained? When was the last update? Does your site actually need it? Remove anything unused. Every inactive plugin is an attack surface with zero upside.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Check your login URL
&lt;/h3&gt;

&lt;p&gt;If your login page is still at &lt;code&gt;/wp-login.php&lt;/code&gt; or &lt;code&gt;/wp-admin&lt;/code&gt;, every automated brute-force campaign knows exactly where to hit. Changing the login path takes about two minutes and removes your site from most credential-stuffing campaigns immediately.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Write a one-page recovery plan
&lt;/h3&gt;

&lt;p&gt;Answer: who do I call, what do I check first, where are my backups, how do I put the site in maintenance mode? The 73% without a plan make recovery take 3-4x longer, not because they are less capable, but because they are making decisions under stress without a framework.&lt;/p&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;According to data compiled from 18 original research sources:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;11,334&lt;/strong&gt; new WordPress vulnerabilities in 2025, the highest ever, a 42% increase year-on-year (Patchstack, Feb 2026)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;~13,000&lt;/strong&gt; WordPress sites hacked per day, totaling 4.7 million annually (WPMayor via Sophos)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;5 hours&lt;/strong&gt; median from disclosure to mass exploitation, with 46% of vulnerabilities having no patch at the time of disclosure (Patchstack 2026)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;87.8%&lt;/strong&gt; of WordPress-specific exploits bypass hosting firewalls (Patchstack via Xictron 2026)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;$14,500&lt;/strong&gt; average recovery cost for a small business, versus approximately $8/month for proactive protection (Colorlib, Xictron 2026)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;~90%&lt;/strong&gt; of attacks are preventable through basic security hygiene (OsomStudio 2026)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full version with all 43 data points and inline source citations: &lt;a href="https://hidemywpghost.com/wordpress-security-statistics-2025-2026-43-verified-data-points/" rel="noopener noreferrer"&gt;WordPress Security Statistics 2025-2026&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Data sourced from Patchstack 2026 Whitepaper, Patchstack Mid-Year 2025, Wordfence, Sucuri, Melapress 2025 Security Survey, OsomStudio 2026, Hostinger, WPMayor/Sophos, Colorlib, Xictron 2026, TDW Digital 2025, WebHostMost 2026, WP Edition, The Repository/Patchstack, and HowToWP. All numbers verified as of April 2026.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>cybersecurity</category>
      <category>security</category>
    </item>
    <item>
      <title>The Real Cost of 1 Hour of WordPress Downtime (It's More Than You Think)</title>
      <dc:creator>Calin V.</dc:creator>
      <pubDate>Thu, 09 Apr 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/cifi/the-real-cost-of-1-hour-of-wordpress-downtime-its-more-than-you-think-3gjk</link>
      <guid>https://dev.to/cifi/the-real-cost-of-1-hour-of-wordpress-downtime-its-more-than-you-think-3gjk</guid>
      <description>&lt;p&gt;Most WordPress site owners think about downtime the wrong way.&lt;/p&gt;

&lt;p&gt;They imagine the damage ending the moment the site comes back online. It doesn't.&lt;/p&gt;

&lt;p&gt;The real cost keeps running, in lost sales, developer invoices, search rankings, and customer trust, long after the page loads again.&lt;/p&gt;

&lt;p&gt;Here's what one hour of WordPress downtime actually costs, broken down into the four categories that matter.&lt;/p&gt;




&lt;h2&gt;
  
  
  💸 1. Lost Sales - The Clock Starts Immediately
&lt;/h2&gt;

&lt;p&gt;The moment your site goes down, revenue stops. Every minute a visitor can't reach your store or service page is a potential customer who just went somewhere else.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.atlassian.com/incident-management/kpis/cost-of-downtime" rel="noopener noreferrer"&gt;Atlassian's incident management benchmarks&lt;/a&gt; put the cost-per-minute at &lt;strong&gt;$427 for small businesses&lt;/strong&gt;, which adds up to roughly &lt;strong&gt;$25,000 over a single hour&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;For mid-size businesses, it gets worse. According to a 2024 ITIC survey reported by &lt;a href="https://www.shopify.com/blog/ecommerce-downtime" rel="noopener noreferrer"&gt;Shopify&lt;/a&gt;, the average cost of one hour of downtime for 90% of midsize and large businesses exceeds &lt;strong&gt;$300,000&lt;/strong&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Even if your site generates $10,000/month, that's roughly &lt;strong&gt;$14 in lost revenue every single minute&lt;/strong&gt; your site is down, during business hours.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And here's the detail most people miss: if the downtime happens during a product launch, a sale event, or while a paid ad campaign is running, that number multiplies fast.&lt;/p&gt;




&lt;h2&gt;
  
  
  👨‍💻 2. Developer Fees - The Bill That Arrives After
&lt;/h2&gt;

&lt;p&gt;When a WordPress site goes down due to a hack or security breach, you can't just refresh the page and move on. Someone has to diagnose the problem, clean it up, and close the vulnerability that caused it.&lt;/p&gt;

&lt;p&gt;That someone charges by the hour.&lt;/p&gt;

&lt;p&gt;According to &lt;a href="https://www.wpnearme.com/wordpress-developer-rates-pricing-2026/" rel="noopener noreferrer"&gt;WPNearMe's 2026 WordPress developer rate analysis&lt;/a&gt;, hack recovery and security hardening work sits at the &lt;strong&gt;higher end of the $15–$200+/hr range&lt;/strong&gt; for US-based developers.&lt;/p&gt;

&lt;p&gt;And cleanup costs? According to &lt;a href="https://devverx.us/blog/how-much-does-wordpress-development-cost/" rel="noopener noreferrer"&gt;devverx.us&lt;/a&gt;, remediation after a security compromise typically runs &lt;strong&gt;$1,000–$5,000+ in developer time&lt;/strong&gt;, plus whatever SEO damage occurred if Google flagged the site as malicious.&lt;/p&gt;

&lt;p&gt;That's assuming you catch it quickly &lt;em&gt;and&lt;/em&gt; hire someone who does it right the first time.&lt;/p&gt;

&lt;p&gt;The reality is messier. As &lt;a href="https://www.whatarmy.com/blog/cost-of-hacked-website/" rel="noopener noreferrer"&gt;WhatArmy documents&lt;/a&gt;, developers often remove visible malicious code without addressing the root vulnerability, and the site gets hacked again a week later, restarting the entire cycle.&lt;/p&gt;




&lt;h2&gt;
  
  
  📉 3. SEO Hit - The Damage That Outlasts the Downtime
&lt;/h2&gt;

&lt;p&gt;This is the cost most site owners don't see coming. And it's the one that stings longest.&lt;/p&gt;

&lt;p&gt;When a site is hacked and starts serving malware, displaying spam pages, or silently redirecting visitors, Google notices. Fast.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Google actually does
&lt;/h3&gt;

&lt;p&gt;If your site is flagged, users may see a &lt;strong&gt;"Deceptive site ahead"&lt;/strong&gt; warning directly in Chrome before they even reach your site. That's not a minor inconvenience; it's a hard stop for every visitor.&lt;/p&gt;

&lt;p&gt;According to &lt;a href="https://support.google.com/webmasters/answer/163634" rel="noopener noreferrer"&gt;Google's own documentation on hacked sites&lt;/a&gt;, recovering from a manual penalty can take &lt;strong&gt;weeks to months&lt;/strong&gt;, even after a full cleanup.&lt;/p&gt;

&lt;h3&gt;
  
  
  The traffic math
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://lagnis.com/blog/true-cost-website-downtime-ecommerce-2025/" rel="noopener noreferrer"&gt;A study cited by Moz&lt;/a&gt; found that websites with roughly 8.76 hours of annual downtime, the equivalent of 99.9% uptime, can lose &lt;strong&gt;up to 20% of organic search traffic&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;More severe hacks can result in &lt;strong&gt;50–80% organic traffic loss&lt;/strong&gt; during the recovery window, as documented by &lt;a href="https://www.snazzy.solutions/blog/wordpress/hacked-wordpress-site-cost" rel="noopener noreferrer"&gt;Snazzy Solutions&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The recovery timeline
&lt;/h3&gt;

&lt;p&gt;Per &lt;a href="https://searchengineland.com/guide/google-penalty" rel="noopener noreferrer"&gt;Search Engine Land's penalty guide&lt;/a&gt; and multiple SEO sources: recovery from a Google penalty after a hack typically takes &lt;strong&gt;3–6 months&lt;/strong&gt;, if it recovers at all.&lt;/p&gt;

&lt;p&gt;While your rankings are depressed, competitors are capturing the traffic that would have been yours. Some of that audience never comes back.&lt;/p&gt;




&lt;h2&gt;
  
  
  🤝 4. Loss of Trust - The Cost You Can't Put on an Invoice
&lt;/h2&gt;

&lt;p&gt;Trust is the hardest thing to rebuild after a site goes down, especially when the cause was a hack.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;77% of consumers abandon retailers after encountering site errors&lt;/strong&gt;, according to data &lt;a href="https://siteqwality.com/blog/true-cost-website-downtime-2025/" rel="noopener noreferrer"&gt;cited by Site Qwality's 2025 downtime analysis&lt;/a&gt;. Not 77% who complain. 77% who quietly leave and don't come back.&lt;/p&gt;

&lt;p&gt;After a downtime or security incident:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Customer acquisition costs rise 15–25%&lt;/strong&gt;, customers become more skeptical and require more convincing to convert&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Conversion rates drop 10–20%&lt;/strong&gt; in the weeks following the incident&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Paid advertising spend often needs to increase 30–50%&lt;/strong&gt; to compensate for reduced organic traffic and lower conversion rates&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;(Source: &lt;a href="https://lagnis.com/blog/true-cost-website-downtime-ecommerce-2025/" rel="noopener noreferrer"&gt;Lagnis downtime cost analysis, 2025&lt;/a&gt;)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For agencies managing client sites, the damage goes further: a hacked client site can cost you the relationship entirely, and every referral that client would have sent you. That doesn't show up in any report, but it's very real.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Full Picture: 1 Hour of WordPress Downtime
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cost Category&lt;/th&gt;
&lt;th&gt;Estimated Impact&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Lost revenue (1 hour, SMB)&lt;/td&gt;
&lt;td&gt;$1,500 – $25,000+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Developer cleanup fees&lt;/td&gt;
&lt;td&gt;$1,000 – $5,000+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SEO traffic loss&lt;/td&gt;
&lt;td&gt;20–80% organic drop (3–6 months recovery)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Customer trust &amp;amp; conversion loss&lt;/td&gt;
&lt;td&gt;15–25% higher acquisition costs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Combined real-world impact&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$5,000 – $50,000+&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And that's assuming the breach is caught within the hour. Most aren't.&lt;/p&gt;

&lt;p&gt;According to &lt;a href="https://www.encomputers.com/2024/03/small-business-cost-of-downtime/" rel="noopener noreferrer"&gt;ITIC's 2024 Hourly Cost of Downtime Report (cited by EnComputers)&lt;/a&gt;, &lt;strong&gt;84% of firms cite security as their number one cause of unplanned downtime&lt;/strong&gt;, and many infections sit undetected for days or weeks.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Prevention Math Is Simple
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.snazzy.solutions/blog/wordpress/hacked-wordpress-site-cost" rel="noopener noreferrer"&gt;Ongoing WordPress security and maintenance typically costs $30–$200/month&lt;/a&gt; depending on the level of service.&lt;/p&gt;

&lt;p&gt;Compare that to the $5,000–$50,000+ total impact of a single security breach.&lt;/p&gt;

&lt;p&gt;This is the core argument behind &lt;strong&gt;proactive hack prevention&lt;/strong&gt;: don't wait for the break-in and then clean up, make the site hard enough to target that bots move on before the attack begins.&lt;/p&gt;

&lt;p&gt;A plugin like &lt;strong&gt;&lt;a href="https://wpghost.com" rel="noopener noreferrer"&gt;WP Ghost (Hide My WP Ghost)&lt;/a&gt;&lt;/strong&gt; is built specifically around this logic. Instead of scanning for damage after a breach, it prevents attacks from reaching your site in the first place by:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Hiding the default WordPress paths bots use to fingerprint your CMS (&lt;code&gt;/wp-admin&lt;/code&gt;, &lt;code&gt;/wp-login.php&lt;/code&gt;, plugin paths)&lt;/li&gt;
&lt;li&gt;Blocking malicious traffic with an &lt;strong&gt;8G firewall&lt;/strong&gt; before it reaches PHP&lt;/li&gt;
&lt;li&gt;Enforcing &lt;strong&gt;2FA and brute force protection&lt;/strong&gt; before an attacker gets near your login&lt;/li&gt;
&lt;li&gt;Blocking AI crawlers and scrapers with firewall-level user-agent rules&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The plugin's own track record, documented in their knowledge base: in more than &lt;strong&gt;10 years&lt;/strong&gt;, not a single reported breach on sites that had WP Ghost correctly configured with its core protections active.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Quick Self-Assessment
&lt;/h2&gt;

&lt;p&gt;Want to know your specific number? Run this calculation:&lt;/p&gt;

&lt;p&gt;Monthly revenue ÷ 720 hours = hourly revenue at risk&lt;br&gt;
Hourly revenue × realistic downtime hours = immediate revenue loss&lt;br&gt;
Add: $1,000–$5,000 developer fees&lt;br&gt;
Add: 3–6 months of depressed organic traffic&lt;br&gt;
Add: 15–25% increase in customer acquisition costs&lt;/p&gt;

&lt;p&gt;For most businesses, even small ones, the number is uncomfortable.&lt;/p&gt;

&lt;p&gt;The question isn't whether you can afford to invest in security. It's whether you can afford not to.&lt;/p&gt;




&lt;h2&gt;
  
  
  Sources:
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.atlassian.com/incident-management/kpis/cost-of-downtime" rel="noopener noreferrer"&gt;Atlassian: Cost of Downtime Calculator&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.encomputers.com/2024/03/small-business-cost-of-downtime/" rel="noopener noreferrer"&gt;ITIC 2024 Hourly Cost of Downtime Report (via EnComputers)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.shopify.com/blog/ecommerce-downtime" rel="noopener noreferrer"&gt;Shopify: How to Minimize Ecommerce Downtime&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://siteqwality.com/blog/true-cost-website-downtime-2025/" rel="noopener noreferrer"&gt;Site Qwality: True Cost of Website Downtime 2025&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://lagnis.com/blog/true-cost-website-downtime-ecommerce-2025/" rel="noopener noreferrer"&gt;Lagnis: True Cost of Website Downtime for E-commerce 2025&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.wpnearme.com/wordpress-developer-rates-pricing-2026/" rel="noopener noreferrer"&gt;WPNearMe: WordPress Developer Rates 2026&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://devverx.us/blog/how-much-does-wordpress-development-cost/" rel="noopener noreferrer"&gt;devverx.us: WordPress Development Cost&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.snazzy.solutions/blog/wordpress/hacked-wordpress-site-cost" rel="noopener noreferrer"&gt;Snazzy Solutions: Cost to Fix a Hacked WordPress Site&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://searchengineland.com/guide/google-penalty" rel="noopener noreferrer"&gt;Search Engine Land: Google Penalty Guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://support.google.com/webmasters/answer/163634" rel="noopener noreferrer"&gt;Google: Hacked Sites Help&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://wpghost.com/kb/" rel="noopener noreferrer"&gt;WP Ghost Documentation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>security</category>
      <category>wordpress</category>
      <category>cybersecurity</category>
      <category>vulnerabilities</category>
    </item>
  </channel>
</rss>
