Guide

WordPress plugin silently activated or deactivated — who did it and when

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.

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_typeplugin_activated, plugin_deactivated, plugin_installed, plugin_deleted, theme_switched, core_updated
  • target — the plugin slug (e.g. wordfence/wordfence.php)
  • actor_user_id — the WordPress user ID that triggered it (or 0 if it was a system / WP-CLI / unauthenticated action)
  • actor_login — the username
  • request_ip — source IP
  • request_uri — usually /wp-admin/plugins.php but can be /wp-admin/admin-ajax.php, /wp-admin/update.php, or a REST endpoint
  • timestamp

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_change events with actor_user_id from compromised accounts (those accounts should be locked or rotated).
  • No new wp.state_change events with actor_user_id=0 unless explicitly expected (CI/CD, scheduled tasks).
  • No new wp.state_change from unfamiliar IPs.

Signals that should appear normally:

  • auth.attempt events showing only known admins logging in from known networks.
  • http.request to /wp-admin/plugins.php correlated 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 switchedwp.state_change with change_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 administrator capability. Surfaces as a wp.state_change on user metadata or a db.write to wp_usermeta, often paired with a fresh auth.attempt success from an unfamiliar IP.
  • WordPress core auto-updated to a development branchwp.state_change with change_type=core_updated and an unexpected version string.
  • REST API enumeration before plugin activation — supporting http.request spike on /wp-json/wp/v2/users followed by an auth.attempt success and a wp.state_change. Classic reconnaissance-then-foothold pattern.
  • Credential stuffing leading to plugin installation — burst of auth.attempt failures, one success, then wp.state_change with change_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.

Logystera Logystera
Monitoring for WordPress and Drupal sites. Install a plugin or module to catch silent failures — cron stalls, failed emails, login attacks, PHP errors — before users report them.
Company
Copyright © 2026 Logystera. All rights reserved.