DEV Community

Loyal Security WP
Loyal Security WP

Posted on

Auditing WordPress Changes - What Core Hooks Don't Tell You

WordPress core gives you hooks for a lot of individual events — wp_login_failed,
activated_plugin, profile_update — but no unified log. If you want to answer "what changed on
this site, and when," you're stitching together a dozen separate hooks yourself, or you're
guessing after the fact and hoping the answer is still in a log file somewhere.

The hooks worth capturing for a basic audit trail

You don't need to log everything WordPress fires — most of it is noise. A useful baseline audit
trail covers:

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

Why storage, not capture, is the actual hard part

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

The fixes are boring but necessary:

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

A worked example

As a learning example — not production-ready, but enough to see the shape of it — here's a
minimal table and dispatcher:

<?php
function prefix_create_audit_log_table() {
    global $wpdb;

    $table_name      = $wpdb->prefix . 'prefix_audit_log';
    $charset_collate = $wpdb->get_charset_collate();

    $sql = "CREATE TABLE {$table_name} (
        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)
    ) {$charset_collate};";

    require_once ABSPATH . 'wp-admin/includes/upgrade.php';
    dbDelta( $sql );
}

add_action( 'activated_plugin', 'prefix_log_activated_plugin' );

function prefix_log_activated_plugin( $plugin ) {
    prefix_write_audit_log_entry( 'activated_plugin', array( 'plugin' => $plugin ) );
}

function prefix_write_audit_log_entry( $event_type, $event_data ) {
    global $wpdb;

    $wpdb->insert(
        $wpdb->prefix . 'prefix_audit_log',
        array(
            'event_type' => $event_type,
            'event_data' => wp_json_encode( $event_data ),
            'user_id'    => get_current_user_id(),
            'created_at' => current_time( 'mysql' ),
        )
    );
}
Enter fullscreen mode Exit fullscreen mode

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


Loyal Security's free audit log solves this with
a storage cap, a clear summary, and WP-CLI commands for stats and pruning
(wp loyal-security logs stats, wp loyal-security logs prune) if you'd rather not build and
maintain this yourself.

Top comments (0)