Guide
WordPress plugin silently activated or deactivated — who did it and when
1. Problem
You open the WordPress admin and notice something is off. A plugin you do not remember installing is active. Or a security plugin you rely on — Wordfence, iThemes Security, a backup plugin — is suddenly deactivated. Maybe a contact form is now disabled, maybe a caching plugin you never touched is running, maybe a "WP File Manager" you never installed is sitting at the top of the list with a green dot.
You did not do this. Nobody on your team admits to doing it. The site is up, the homepage loads, monitoring is green. But the plugins page tells a different story.
This is the WordPress version of "wordpress plugin activated by itself." The site is not down. It is compromised, drifting, or being prepared for something worse. The dashboard shows the current state — it does not show who flipped the switch, when, or from which IP. By default, WordPress keeps no record of plugin activations or deactivations. If you want to audit who activated a plugin in WordPress, the core has nothing to give you.
This guide walks through what just happened, why it is invisible, and how to reconstruct the change from logs — specifically the wp.state_change signal that fires the moment a plugin is activated, deactivated, installed, or deleted.
2. Impact
A silent plugin activation is rarely innocent. The realistic scenarios:
- Compromised admin account. An attacker logged in (stolen password, reused credential, session hijack) and activated a backdoor plugin to maintain access even after a password reset.
- File upload exploit. A vulnerable plugin let an attacker drop a PHP file into
wp-content/plugins/and activate it through the admin or directly via a forged request. - Malicious update. A legitimate plugin you installed was sold or hijacked and pushed an update that activates additional code.
- Insider error or misuse. A contractor, agency, or junior admin activated something they should not have, and nobody flagged it.
- Security plugin disabled before an attack. Wordfence or similar is deactivated minutes before a wave of brute-force or upload attempts — a classic precursor pattern.
The damage path: backdoor PHP gets a foothold, credentials and sessions get exfiltrated, SEO spam gets injected into posts, the site becomes part of a redirect chain, your hosting account gets flagged, customers lose trust. If a security plugin was the one disabled, the attacker has likely already escalated. Every hour you do not know about this state change is an hour the attacker keeps working.
3. Why It’s Hard to Spot
WordPress core is silent on this. There is no built-in audit log. The plugins page shows you the current state — active or inactive — but never the history. No "activated by" column. No "last changed" timestamp. If you ask wp_options or the database directly, the only artifact is the active_plugins array, which is overwritten on every change with no version history.
Uptime monitors miss it entirely. The site is up. Pages return 200. TTFB is normal. A backdoor plugin that adds a hidden REST route does not change a single thing a synthetic check can see.
Email notifications? WordPress does not send any for plugin activation. Some security plugins do — but if the activation event is the attacker disabling that very plugin, the notification never fires.
The admin UI itself is misleading. The plugin list is sorted alphabetically and a newly activated plugin slots into its position quietly. If you have 40 plugins, a 41st that appeared overnight is easy to miss. The "Recently Activated" tab only shows plugins that were deactivated, not activated.
This is the textbook silent failure: an event with serious security implications, no native logging, no native alerting, and no visible symptom until somebody manually opens the plugins page and notices.
4. Cause
When somebody activates a plugin, WordPress fires the activated_plugin action hook. Deactivation fires deactivated_plugin. Installation runs through upgrader_process_complete. Deletion runs through delete_plugin. The Logystera WordPress plugin hooks all of these and emits a single normalized signal: wp.state_change.
A wp.state_change event is the audit record of a structural change to the site. Its payload includes:
change_type—plugin_activated,plugin_deactivated,plugin_installed,plugin_deleted,theme_switched,core_updatedtarget— the plugin slug (e.g.wordfence/wordfence.php)actor_user_id— the WordPress user ID that triggered it (or0if it was a system / WP-CLI / unauthenticated action)actor_login— the usernamerequest_ip— source IPrequest_uri— usually/wp-admin/plugins.phpbut can be/wp-admin/admin-ajax.php,/wp-admin/update.php, or a REST endpointtimestamp
This is the signal that answers "who activated this plugin." Not the database. Not the plugin file. The signal, captured at the moment the hook fires, with the actor and request context attached.
In parallel, supporting signals tell the rest of the story. auth.attempt shows the login that produced the session — successful or failed. http.request shows the admin endpoint hits surrounding the change: POST /wp-admin/plugins.php?action=activate, GET /wp-admin/update.php, POST /wp-admin/admin-ajax.php?action=install-plugin. Correlated together, these three signals reconstruct the full sequence: who logged in, from where, what they clicked, and what changed.
5. Solution
5.1 Diagnose (logs first)
Stop looking at the WordPress admin. Go to logs.
1. Web server access logs — find the activation request.
The activation HTTP request is the closest unambiguous artifact at the infrastructure layer. Activation goes through POST /wp-admin/plugins.php with action=activate in the body, or POST /wp-admin/admin-ajax.php with action=activate-plugin. Bulk activation hits plugins.php with action=activate-selected.
grep -E "POST /wp-admin/plugins.php|admin-ajax.php.*activate" /var/log/nginx/access.log
grep "wp-admin/update.php" /var/log/nginx/access.log | grep -i "action=install"
These queries surface the raw HTTP layer of the change — the same events that produce http.request signals in Logystera with request_path=/wp-admin/plugins.php and method POST.
2. PHP error log — confirm the hook fired and check for activation errors.
A failed activation (PHP error during the plugin's register_activation_hook) leaves traces:
grep -E "Plugin .* could not be activated|activated_plugin|deactivated_plugin" /var/log/php-fpm/error.log
tail -f /var/log/php-fpm/www-error.log
3. WordPress audit/debug log — if WP_DEBUG_LOG is on.
tail -n 200 /var/www/wp-content/debug.log | grep -iE "plugin|activate|deactivate"
4. Database — current state plus any plugin-managed audit tables.
SELECT option_value FROM wp_options WHERE option_name = 'active_plugins';
SELECT option_value FROM wp_options WHERE option_name = 'recently_activated';
recently_activated is a serialized array of plugins WordPress recently deactivated, with timestamps. It is the one native breadcrumb. It tells you nothing about activations and nothing about who did it.
5. The signal you actually want: wp.state_change.
If the Logystera WordPress plugin is installed, every activation, deactivation, installation, and deletion has been emitted as wp.state_change with the full actor and request context. Filter on:
event_type=wp.state_change AND change_type=plugin_activated
Then correlate with auth.attempt events for the same actor_user_id in the preceding 30 minutes — that is the login that produced the session. And with http.request events for /wp-admin/plugins.php from the same request_ip to confirm the click path. The three signals together give you: who, when, from where, after what login, through what endpoint.
If actor_user_id=0 on the wp.state_change, the activation was not made through a logged-in admin session. That means WP-CLI, a cron job, an mu-plugin, direct DB write, or an unauthenticated exploit. That is a much more serious signal than a logged-in admin doing something unexpected.
5.2 Root Causes
(see root causes inline in 5.3 Fix)
5.3 Fix
Map the cause to the signal pattern, then fix the underlying access path.
Cause A: Unauthorized admin session. Signal pattern: wp.state_change with actor_user_id of a real admin, preceded by auth.attempt (success) from an unfamiliar IP, country, or user agent. Fix: force-logout all sessions (wp user session destroy --all), rotate every admin password, enable 2FA, audit wp_users and wp_usermeta for accounts you did not create, and review the wp_capabilities meta for unexpected administrator roles.
Cause B: Compromised plugin or theme used as upload vector. Signal pattern: wp.state_change with change_type=plugin_installed followed immediately by plugin_activated, where the plugin slug is unknown to your team. Often correlated with prior http.request POSTs to a vulnerable plugin endpoint. Fix: deactivate and delete the unknown plugin. Scan wp-content/plugins/ and wp-content/uploads/ for PHP files (find wp-content/uploads -name "*.php"). Patch or remove the vulnerable plugin that allowed the upload. Restore from a clean backup if integrity is in doubt.
Cause C: Security plugin disabled before further activity. Signal pattern: wp.state_change with change_type=plugin_deactivated and target matching a security plugin (wordfence, better-wp-security, sucuri-scanner), followed by a spike in auth.attempt failures, REST enumeration, or new plugin_installed events. Fix: re-activate the security plugin, lock the admin user it ran under, review what changed in the gap window, and treat the site as compromised until proven otherwise.
Cause D: WP-CLI, cron, or direct DB activation. Signal pattern: wp.state_change with actor_user_id=0 and a request_uri that is empty, cli, or a non-admin path. Fix: review server SSH access logs, check crontab -l for both root and the web user, audit mu-plugins/, and confirm no deployment pipeline pushed an unexpected change.
Cause E: Legitimate but undocumented action. Signal pattern: wp.state_change from a real admin during business hours, from a known IP. Fix: nothing technical — but tighten change-management. Require a record for every plugin change. The wp.state_change log becomes that record.
5.4 Verify
You are looking for two things: the absence of unauthorized wp.state_change events, and the absence of the access path that allowed them.
Signals that should stop appearing:
- No new
wp.state_changeevents withactor_user_idfrom compromised accounts (those accounts should be locked or rotated). - No new
wp.state_changeevents withactor_user_id=0unless explicitly expected (CI/CD, scheduled tasks). - No new
wp.state_changefrom unfamiliar IPs.
Signals that should appear normally:
auth.attemptevents showing only known admins logging in from known networks.http.requestto/wp-admin/plugins.phpcorrelated only with deliberate admin sessions.
What to grep:
grep "wp.state_change" /var/log/logystera/agent.log | tail -100
grep -E "actor_user_id=0|actor_login=$" /var/log/logystera/agent.log
Timeframe: monitor for 48 hours of normal traffic. If no unexpected wp.state_change events appear, no auth.attempt failures from suspicious sources spike, and the site's plugin list matches the documented expected state, the immediate incident is resolved. The underlying weakness — no native audit trail — is not.
A clean state under monitoring looks like: zero wp.state_change events on most days, occasional ones tied to a known admin and a documented change ticket. Anything else is a question that needs an answer.
6. How to Catch This Early
Fixing it is straightforward once you know the cause. The hard part is knowing it happened at all.
This issue surfaces as wp.state_change.
The hard part is not fixing a silent plugin activation. The hard part is knowing it happened in the first place.
WordPress core gives you nothing here. No log, no notification, no history. Most security plugins log this internally — but they store it in the same database the attacker just gained access to, and they can themselves be deactivated to silence the trail. Any audit that lives inside the application it is auditing has the same fragility as the application.
This is exactly the kind of failure that requires log intelligence sitting outside the WordPress process. Every plugin activation, deactivation, installation, and deletion produces a wp.state_change signal that Logystera captures the moment the WordPress hook fires, with the acting user, IP, and request context attached. The signal leaves the WordPress instance immediately and is correlated with auth.attempt (who logged in) and http.request (what they clicked).
A rule on top of wp.state_change — fire on any plugin change outside business hours, on any change with actor_user_id=0, on any deactivation of a known security plugin, on any installation of a plugin not in the approved list — turns a blind spot into a real-time alert. The goal is not to stop legitimate plugin changes. The goal is to make sure no plugin change happens without being seen.
7. Related Silent Failures
Other wp.state_change and adjacent failures worth watching:
- Theme silently switched —
wp.state_changewithchange_type=theme_switched. Often used to drop SEO spam or backdoors into a custom theme nobody reviews. - Admin role escalation — a non-admin user suddenly has the
administratorcapability. Surfaces as awp.state_changeon user metadata or adb.writetowp_usermeta, often paired with a freshauth.attemptsuccess from an unfamiliar IP. - WordPress core auto-updated to a development branch —
wp.state_changewithchange_type=core_updatedand an unexpected version string. - REST API enumeration before plugin activation — supporting
http.requestspike on/wp-json/wp/v2/usersfollowed by anauth.attemptsuccess and awp.state_change. Classic reconnaissance-then-foothold pattern. - Credential stuffing leading to plugin installation — burst of
auth.attemptfailures, one success, thenwp.state_changewithchange_type=plugin_installed. The full kill chain in three signals.
Each one is invisible in the WordPress admin. Each one is a single signal away from being obvious.
See what's actually happening in your WordPress system
Connect your site. Logystera starts monitoring within minutes.