<?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: Loyal Security WP</title>
    <description>The latest articles on DEV Community by Loyal Security WP (@loyalsecuritywp).</description>
    <link>https://dev.to/loyalsecuritywp</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%2F4201956%2F0b2ac1c0-6a00-4088-a181-fd3f080f02f6.png</url>
      <title>DEV Community: Loyal Security WP</title>
      <link>https://dev.to/loyalsecuritywp</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/loyalsecuritywp"/>
    <language>en</language>
    <item>
      <title>Auditing WordPress Changes - What Core Hooks Don't Tell You</title>
      <dc:creator>Loyal Security WP</dc:creator>
      <pubDate>Tue, 27 Oct 2026 14:07:12 +0000</pubDate>
      <link>https://dev.to/loyalsecuritywp/auditing-wordpress-changes-what-core-hooks-dont-tell-you-2c49</link>
      <guid>https://dev.to/loyalsecuritywp/auditing-wordpress-changes-what-core-hooks-dont-tell-you-2c49</guid>
      <description>&lt;p&gt;WordPress core gives you hooks for a lot of individual events — &lt;code&gt;wp_login_failed&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;activated_plugin&lt;/code&gt;, &lt;code&gt;profile_update&lt;/code&gt; — but no unified log. If you want to answer "what changed on&lt;br&gt;
this site, and when," you're stitching together a dozen separate hooks yourself, or you're&lt;br&gt;
guessing after the fact and hoping the answer is still in a log file somewhere.&lt;/p&gt;
&lt;h3&gt;
  
  
  The hooks worth capturing for a basic audit trail
&lt;/h3&gt;

&lt;p&gt;You don't need to log everything WordPress fires — most of it is noise. A useful baseline audit&lt;br&gt;
trail covers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;activated_plugin&lt;/code&gt; / &lt;code&gt;deactivated_plugin&lt;/code&gt; — plugin changes are one of the most common vectors
for "something changed and nobody admits to it."&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;switch_theme&lt;/code&gt; — same reasoning, lower frequency, still worth knowing.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;set_user_role&lt;/code&gt; — privilege changes are the single highest-value thing to audit on any
multi-admin site.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;profile_update&lt;/code&gt; and &lt;code&gt;added_user_meta&lt;/code&gt; filtered to role-adjacent meta keys — catches privilege
escalation that doesn't go through &lt;code&gt;set_user_role&lt;/code&gt; directly.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;wp_login&lt;/code&gt; / &lt;code&gt;wp_login_failed&lt;/code&gt; — who got in, and who tried to.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;update_option&lt;/code&gt;, filtered to a sensitive-options allowlist — things like the site URL, active
plugins list, or a security plugin's own settings. Logging &lt;em&gt;every&lt;/em&gt; option write is how you end
up capturing autosave noise and transient churn instead of signal.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  Why storage, not capture, is the actual hard part
&lt;/h3&gt;

&lt;p&gt;Hooking these actions is the easy 20%. The hard part is what happens six months later: a naive&lt;br&gt;
"log everything to a custom table forever" approach quietly grows into a 40 MB table nobody can&lt;br&gt;
query without timing out the admin page that's supposed to display it.&lt;/p&gt;

&lt;p&gt;The fixes are boring but necessary:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;row cap or time-based pruning&lt;/strong&gt; policy decided up front, not bolted on after the table
becomes a problem.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;storage summary visible to the admin&lt;/strong&gt; — "12,400 rows, oldest entry 90 days ago" — so
nobody discovers the problem by way of a slow dashboard.&lt;/li&gt;
&lt;li&gt;Pruning that runs on a schedule, not a button someone has to remember to click.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  A worked example
&lt;/h3&gt;

&lt;p&gt;As a learning example — not production-ready, but enough to see the shape of it — here's a&lt;br&gt;
minimal table and dispatcher:&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="cp"&gt;&amp;lt;?php&lt;/span&gt;
&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;prefix_create_audit_log_table&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;global&lt;/span&gt; &lt;span class="nv"&gt;$wpdb&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nv"&gt;$table_name&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$wpdb&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'prefix_audit_log'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$charset_collate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$wpdb&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get_charset_collate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nv"&gt;$sql&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"CREATE TABLE &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$table_name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; (
        id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
        event_type VARCHAR(64) NOT NULL,
        event_data LONGTEXT NULL,
        user_id BIGINT UNSIGNED NULL,
        created_at DATETIME NOT NULL,
        PRIMARY KEY  (id),
        KEY event_type (event_type),
        KEY created_at (created_at)
    ) &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$charset_collate&lt;/span&gt;&lt;span class="si"&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;require_once&lt;/span&gt; &lt;span class="no"&gt;ABSPATH&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'wp-admin/includes/upgrade.php'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;dbDelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$sql&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'activated_plugin'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'prefix_log_activated_plugin'&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;prefix_log_activated_plugin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$plugin&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;prefix_write_audit_log_entry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'activated_plugin'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'plugin'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$plugin&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;function&lt;/span&gt; &lt;span class="n"&gt;prefix_write_audit_log_entry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$event_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$event_data&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;global&lt;/span&gt; &lt;span class="nv"&gt;$wpdb&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nv"&gt;$wpdb&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nv"&gt;$wpdb&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'prefix_audit_log'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s1"&gt;'event_type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$event_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'event_data'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;wp_json_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$event_data&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="s1"&gt;'user_id'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;get_current_user_id&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="s1"&gt;'created_at'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;current_time&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'mysql'&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wire the same &lt;code&gt;prefix_write_audit_log_entry()&lt;/code&gt; call into &lt;code&gt;deactivated_plugin&lt;/code&gt;, &lt;code&gt;switch_theme&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;set_user_role&lt;/code&gt;, and the rest of the list above, and you have the shape of a real audit trail —&lt;br&gt;
missing only the pruning job and the admin summary that make it sustainable long-term.&lt;/p&gt;




&lt;p&gt;&lt;a href="https://wordpress.org/plugins/loyal-security/" rel="noopener noreferrer"&gt;Loyal Security&lt;/a&gt;'s free audit log solves this with&lt;br&gt;
a storage cap, a clear summary, and WP-CLI commands for stats and pruning&lt;br&gt;
(&lt;code&gt;wp loyal-security logs stats&lt;/code&gt;, &lt;code&gt;wp loyal-security logs prune&lt;/code&gt;) if you'd rather not build and&lt;br&gt;
maintain this yourself.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>security</category>
      <category>logging</category>
      <category>php</category>
    </item>
    <item>
      <title>The Two URLs Every WordPress Attacker Tries First (And How to Stop Guessing Being a Risk)</title>
      <dc:creator>Loyal Security WP</dc:creator>
      <pubDate>Tue, 27 Oct 2026 14:02:01 +0000</pubDate>
      <link>https://dev.to/loyalsecuritywp/the-two-urls-every-wordpress-attacker-tries-first-and-how-to-stop-guessing-being-a-risk-3d47</link>
      <guid>https://dev.to/loyalsecuritywp/the-two-urls-every-wordpress-attacker-tries-first-and-how-to-stop-guessing-being-a-risk-3d47</guid>
      <description>&lt;p&gt;If you've ever checked your WordPress server logs, you've seen it: a steady stream of requests to&lt;br&gt;
&lt;code&gt;/wp-login.php&lt;/code&gt; and &lt;code&gt;/wp-admin&lt;/code&gt; from IPs you've never heard of, at all hours, with no referrer.&lt;br&gt;
That's not targeted. It's automated. Every WordPress site on the internet gets this traffic,&lt;br&gt;
because the login URL is the same on every default install, which means a bot written once works&lt;br&gt;
against millions of sites forever.&lt;/p&gt;
&lt;h3&gt;
  
  
  Why the default login path is a structural problem, not a bug
&lt;/h3&gt;

&lt;p&gt;WordPress can't ship a random login URL by default. Plugins link straight to &lt;code&gt;wp-login.php&lt;/code&gt;,&lt;br&gt;
documentation assumes it exists, and a huge number of integrations (password reset emails, SSO&lt;br&gt;
redirects, staging-to-production migrations) depend on that path being predictable. Changing it&lt;br&gt;
is an opt-in hardening step you take on your own site, not something core can fix for everyone at&lt;br&gt;
once without breaking things that currently work.&lt;/p&gt;

&lt;p&gt;That's why "just rename wp-login.php" isn't a core patch waiting to happen — it's a trade-off&lt;br&gt;
between predictability (which every integration relies on) and obscurity (which only helps once&lt;br&gt;
you've opted in).&lt;/p&gt;
&lt;h3&gt;
  
  
  What actually happens during a credential-stuffing run
&lt;/h3&gt;

&lt;p&gt;Watch the pattern for a few minutes on an unprotected site and it's remarkably consistent:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Request rate&lt;/strong&gt; is steady, not bursty — slow enough to stay under most rate limits you'd set by
accident, fast enough to try thousands of combinations a day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Usernames&lt;/strong&gt; cluster around &lt;code&gt;admin&lt;/code&gt;, the site's own domain name, and a handful of common first
names — not random strings. These are credential lists built from breach dumps and common
WordPress install conventions, not brute-force in the "try every character" sense.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No referrer, no JS execution&lt;/strong&gt; — these are raw POST requests, not a browser driving a form.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is also why rate-limiting beats CAPTCHA for this specific threat. A CAPTCHA assumes the&lt;br&gt;
attacker is a human or a bot without a human fallback; credential-stuffing bots are neither —&lt;br&gt;
they just don't have infinite patience for a &lt;code&gt;429 Too Many Requests&lt;/code&gt; response slowing them to a&lt;br&gt;
crawl. Rate-limiting attacks the bot's actual constraint (time), where CAPTCHA attacks a&lt;br&gt;
constraint it may not even have.&lt;/p&gt;
&lt;h3&gt;
  
  
  A minimal hook to see this on your own site
&lt;/h3&gt;

&lt;p&gt;Before reaching for a plugin, you can see this pattern yourself with one hook:&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="cp"&gt;&amp;lt;?php&lt;/span&gt;
&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'wp_login_failed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'prefix_log_failed_login'&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;prefix_log_failed_login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$username&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$ip_address&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;$_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="o"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;sanitize_text_field&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;$_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="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'unknown'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nb"&gt;error_log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nb"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'Failed login for "%s" from %s'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$ip_address&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;Tail your error log for a day on a site that's been live for more than a few weeks, and the&lt;br&gt;
pattern above will be staring back at you — not as a theory, but as your own traffic.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where a custom login URL and rate-limiting actually fit
&lt;/h3&gt;

&lt;p&gt;Moving the login endpoint doesn't stop a targeted attacker who already knows your real URL —&lt;br&gt;
anyone who's phished a password or found it in a leaked database isn't slowed down by this.&lt;br&gt;
What it does is silence the roughly 99% of traffic that's just scanning the default path on every&lt;br&gt;
WordPress site it can find, with no idea yours exists at a different one. Once that noise is&lt;br&gt;
gone, the signal that's left in your logs — the handful of requests actually aimed at your&lt;br&gt;
specific site — becomes much easier to see and act on. Pair that with rate-limiting on whatever&lt;br&gt;
login path you do use, and you've addressed both the volume problem and the determined-attacker&lt;br&gt;
problem with two independent, complementary controls.&lt;/p&gt;




&lt;p&gt;This is exactly the free feature set in &lt;a href="https://wordpress.org/plugins/loyal-security/" rel="noopener noreferrer"&gt;Loyal Security&lt;/a&gt; custom login URL plus brute-force protection, no paywall, no upsell screen in the&lt;br&gt;
way. If you'd rather build and maintain the above yourself, the hook above is a genuine starting&lt;br&gt;
point; if not, that's what the plugin is for.&lt;/p&gt;

&lt;p&gt;We also cover this topic in plain language, for a non-technical audience, on the company blog:&lt;br&gt;
&lt;a href="https://loyalsecuritywp.com/blog/wordpress-default-login-url" rel="noopener noreferrer"&gt;The Two URLs Every Bot Tries First on Your WordPress Site&lt;/a&gt;.&lt;/p&gt;

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