Guide
1. Problem
You open wp-admin/plugins.php and a plugin is active that nobody remembers turning on. Or the opposite: the caching plugin that has been running for two years is suddenly deactivated, and the site is slow, and nobody on the team will admit to touching it. Either way you are left asking a question WordPress has no good answer for: who did this, and when.
The instinct is to ask around. "Did anyone update plugins yesterday?" "Was this the auto-update?" Nobody remembers, because for most people a plugin toggle is a five-second action forgotten the moment the tab closes, and if it wasn't a person at all, there is nobody to ask. Searching "wordpress plugin activated itself" mostly turns up advice to check for malware, the right instinct but the wrong first step: you do not yet know if this was a person, a routine update, or an intruder, and treating all three the same wastes the hour you have to figure it out.
The real question is narrower: at the exact timestamp the plugin's state changed, was a human logged in, was that human authorized, and does the plugin itself look legitimate. Answering that in order tells you whether to shrug, send a Slack message, or start an incident.
A plugin activation or deactivation surfaces as the wp_state_changes_total signal. This guide covers reading that signal for identity and timing, correlating it against who was logged in, and matching the response to what you find, because "confirm and document" and "rotate every credential on the site" are both correct responses to the same symptom, depending on the cause.
2. Impact
An unexplained plugin state change is not automatically an emergency, but treating every one the same, either by ignoring it or by panicking every time, is expensive in both directions.
- A silent deactivation removes protection without a warning. A firewall, caching, or backup plugin deactivated by an auto-update conflict, or by a person who didn't realize what it did, leaves the site exposed or degraded until someone happens to notice. Sites frequently run for days with security tooling quietly off.
- A silent activation is the standard backdoor pattern. Attackers rarely write new admin-visible code from scratch. They drop a plugin, often disguised as a caching or SEO utility, and activate it once. From that point it runs with full plugin privileges: file writes, database access, scheduled tasks, no further login required. The activation event is often the only trace of the entire compromise.
- Team confusion costs real time. Without a timestamped, attributed record, "who did this" becomes a round of messages to everyone with dashboard access, each checking a memory that has already blurred together. An hour lost to a question a log line answers in ten seconds.
- Repeat incidents look identical from the outside. A legitimate auto-update, an agency contractor making an unannounced change, and a compromised account planting a backdoor all present the same way in
wp-admin: a plugin that is on when it should be off, or off when it should be on. Without attribution you cannot tell them apart, so every incident gets escalated to the worst case, or none of them do.
3. Why It’s Hard to Spot
WordPress keeps no durable, visible audit record of who activated or deactivated a plugin, or when. The plugins screen shows current state only. It does not show history.
Under the hood, activation runs through activate_{plugin} and deactivate_{plugin} hooks. These fire, run whatever the plugin registered against them, and vanish. Nothing in core writes the event to a table, a log file, or an email. The only place the transition briefly exists is the single HTTP request that made it, and once that request completes there is no record left to query. Ask WordPress "who activated this plugin on Tuesday" and there is no query that answers it, because the fact was never stored anywhere.
Auto-updates make this worse, not better. When WordPress core silently updates a plugin, it can trigger a deactivate/reactivate cycle as part of the swap, indistinguishable from a person doing the same thing through the UI unless you already know the update job ran at that exact minute.
Security plugins that do log activity usually log their own activation, not every other plugin's, and most stop logging the moment they are the one being deactivated, precisely the scenario you most need visibility into. Server access logs show an HTTP POST to plugins.php with a 302 response, which confirms a request happened but not which plugin, and not whether the requester was who their session claimed.
4. Cause
The wp_state_changes_total signal fires whenever a plugin's active state flips, carrying the plugin identity and, where the request context provides one, the acting user and the timestamp. There are three explanations, in rising order of severity, and each leaves a distinct fingerprint in the signals around it.
- An auto-update toggled it. WordPress's background update routine deactivated and reactivated a plugin as part of a version swap, or a companion plugin was auto-disabled because its parent went inactive. This produces
wp_state_changes_total{change_type="plugin_updated"}immediately alongside the activation event, with no logged-in user in the same request window onwp_user_lifecycle_total. Lowest severity: annoying, rarely harmful. - A human with dashboard access did it and didn't say. An admin, an agency contractor, or a co-maintainer flipped the plugin during a session and moved on without mentioning it. This produces
wp_state_changes_totalwith a realuser_id, matched by an authenticated session onwp_user_lifecycle_totalor the admin request stream in the same window. Middle severity: needs a conversation, not an incident. - A compromise dropped and activated a malicious plugin as a backdoor. An attacker who already has admin access (stolen credentials, a hijacked session, or an exploited upload path) uploads a plugin disguised as something benign and activates it once to establish persistence. This produces
wp_state_changes_totalfor a plugin slug with no matching provenance, often preceded within minutes by a spike inwp_auth_attempts_total{result="success"}from an unfamiliar source. Highest severity: contain first, investigate second.
Logystera emits the state change, the surrounding user activity, and any preceding auth activity as separate signals on one timeline, so distinguishing these three cases is a correlation you read, not a guess.
5. Solution
5.1 Diagnose (logs first)
Do not start by asking the team. Start by pinning the exact timestamp of the change and working outward from it.
1. Find the state change and its timestamp. Check the current plugin list against what you expect, then search the logs for the transition itself.
# Current active plugins, for a quick diff against what you expect
wp plugin list --status=active --format=table
# The state change event itself, if the WordPress debug log is on
grep -nE "plugin_(activated|deactivated)" /var/www/wp-content/debug.log | tail -n 20
A typical entry looks like this:
[14-Jul-2026 03:12:44 UTC] state_change plugin=cache-utils-pro type=plugin_activated user_id=0 source=wp-cron
Read it literally. The plugin slug (cache-utils-pro) tells you what changed. user_id=0 with source=wp-cron means no logged-in person triggered this request, pointing toward the auto-update explanation, not a human or an attacker acting through the dashboard. A nonzero user_id with source=admin-ajax points the other way. This is the wp_state_changes_total signal.
2. Check who was logged in at that timestamp. If a user_id is present, confirm there was an active, legitimate session for that user, not just a cookie that happened to be valid.
# Was this user_id actually authenticated around the timestamp?
grep -nE "user_id=42" /var/www/wp-content/debug.log \
| grep -E "auth\.attempt|wp_admin_requests_total" \
| grep "14-Jul-2026 0[23]:"
# Sanity-check session validity for that user
wp user session list 42
3. Check for a preceding auth surge. If the timestamp lines up with a burst of login attempts, especially a success from a source that doesn't match the named user's usual pattern, treat this as compromise until proven otherwise.
# Auth activity in the 30 minutes before the state change
grep -nE "auth\.attempt" /var/www/wp-content/debug.log \
| grep "14-Jul-2026 02:4[0-9]:\|14-Jul-2026 03:0[0-9]:"
4. Check the plugin's provenance. Is it in the WordPress.org repository under that exact slug, or a known commercial plugin your team actually purchased? Does the folder name look legitimate, or does it carry a suspicious suffix, random characters, or a name close to but not exactly matching a real plugin?
# File age and ownership of the plugin in question
ls -la wp-content/plugins/cache-utils-pro/
# Does it exist in the official repo under this slug?
curl -s -o /dev/null -w "%{http_code}\n" \
https://wordpress.org/plugins/cache-utils-pro/
In Logystera these four checks are one timeline: wp_state_changes_total anchors the moment and names the plugin, wp_user_lifecycle_total confirms or rules out a logged-in actor, and wp_auth_attempts_total shows whether anything unusual happened to get there.
5.2 Root Causes
Map what you found in diagnosis to a cause before you decide how to respond.
user_id=0,source=wp-cron, and a matchingplugin_updatedevent means an auto-update. Cause: routine background maintenance. Signal pair:wp_state_changes_total{change_type="plugin_updated"}alongsidewp_state_changes_total{change_type="plugin_activated"}, no correspondingwp_user_lifecycle_totalentry.- A nonzero
user_id, a valid session for a known team member, a legitimate plugin slug you recognize, and no preceding auth anomaly means a human made the change. Cause: an admin or contractor toggled a plugin and forgot to log it. Signal:wp_state_changes_totalwith a matching, unremarkablewp_user_lifecycle_totalsession. - A plugin slug you don't recognize, a folder that doesn't exist in the official repository, or a preceding spike in
wp_auth_attempts_total{result="success"}from a source that doesn't match the named user's usual login pattern means compromise. Cause: an attacker with admin access planted and activated a backdoor. Signal pattern:wp_auth_attempts_total{result="success"}anomaly, thenwp_state_changes_total{change_type="plugin_activated"}for an unfamiliar slug within minutes.
5.3 Fix
The response depends on which of the three causes the diagnosis pointed to. Do not skip to remediation before you have an answer: over-reacting to a routine update burns a day on incident response for nothing, and under-reacting to a compromise leaves the backdoor in place.
Confirm and document (human). If a known team member made the change, get a one-line confirmation and record it somewhere durable, a change log, a ticket, a pinned message, so the next person who finds this state change in six months isn't starting from zero. This is the cheapest fix and it prevents the same investigation from repeating.
Verify-safe (auto-update). If the update source is confirmed and the plugin's new version behaves correctly, no action is required beyond a quick functional check. Confirm the plugin that was toggled is doing what it should, particularly if it is a security or performance plugin whose failure mode is silent.
# Confirm the plugin is actually active and functioning, not just listed as active
wp plugin status cache-utils-pro
wp eval 'var_dump( is_plugin_active( "cache-utils-pro/cache-utils-pro.php" ) );'
Contain (compromise). If the plugin's provenance doesn't check out or it correlates with an auth anomaly, do not just deactivate it. Deactivating without removing leaves the code on disk, still reachable by a scheduled task or a direct file request in some configurations.
# Deactivate, then remove entirely, don't just toggle it off
wp plugin deactivate cache-utils-pro
wp plugin delete cache-utils-pro
# Full file-integrity scan against known-good WordPress core and repo plugin sources
wp core verify-checksums
wp plugin verify-checksums --all
After removal, force a password reset and invalidate all sessions for the user whose credentials were used to activate the plugin, rotate any application passwords, and review wp_state_changes_total for that user across the preceding 48 hours. The plugin activation is rarely the first thing an attacker does after gaining access, only the most visible.
wp user session destroy 42 --all
wp user update 42 --user_pass=$(openssl rand -base64 24)
5.4 Verify
This is confirmed by the signal returning to an explained state, not by the plugins screen looking normal for one page load.
# Confirm no further unexplained state changes since remediation
grep -nE "plugin_(activated|deactivated)" /var/www/wp-content/debug.log \
| awk -F']' '$1 > "[14-Jul-2026 04:00:00 UTC"'
# Confirm the removed plugin is actually gone, not just deactivated
wp plugin list --status=active | grep -i cache-utils-pro || echo "not present"
Healthy state is every subsequent wp_state_changes_total event carrying a user_id you can attribute to a real action, and no further activity from a source involved in a compromise finding. For the compromise path, watch 48 hours: if wp_auth_attempts_total{result="success"} from the same source and wp_state_changes_total on sensitive paths both stay flat, the access is closed. A single clean plugins.php load after a password reset is not verification; a quiet window is.
6. How to Catch This Early
The hard part of this problem is not diagnosing a state change once you know it happened. It is knowing it happened at all, before the next person to open wp-admin has to ask the same question you just answered.
This issue surfaces as wp_state_changes_total.
Nothing in default WordPress alerts on a plugin activation or deactivation. There is no email, no dashboard notice, no record that survives past the request that made the change. A team of five people with dashboard access has no way to know which of them, if any, flipped a plugin last Tuesday, and an attacker who gains access has no obstacle to activating a backdoor plugin and having it run indefinitely.
A signal that carries the plugin identity, the acting user, and the timestamp on every state change turns "who did this and when" from a round of guesswork into a lookup. The auto-update case resolves in seconds, since the absence of a user is itself the answer. The human case resolves in one message: "was this you at 03:12." The compromise case is the one where minutes matter, and where a durable record, rather than a vanished hook, is the difference between catching it that day and hearing about it from a customer three weeks later.
7. Related Silent Failures
- wp_auth_attempts_total success anomaly: a login succeeding from a source or time pattern that doesn't match the named user's history, the most common precursor to a compromise-driven plugin activation.
- wp_environment_changes_total drift: a PHP or core version change that can trigger a plugin auto-update and deactivate/reactivate cycle, one of the more common false alarms in this guide's diagnosis. See environment drift.
- wp_state_changes_total on wp-config.php: a modified constant or added directive is the other classic persistence mechanism alongside a planted plugin, and often shows up in the same compromise window. See wp-config.php modified.
- wp_user_lifecycle_total unexpected admin creation: attackers who activate a backdoor plugin frequently also create a second admin user in the same session, a second, independent way back in if the plugin is found and removed. See admin user added.
- wp_php_warnings_total or wp_php_fatals_total after an auto-update: the plugin toggle itself was benign, but the new version broke something. Covered in the auto-update broke the site guide.
See what's actually happening in your WordPress system
Connect your site. Logystera starts monitoring within minutes.