Guide
WordPress file integrity monitoring without a paid plugin
1. Problem
A core file under /wp-includes/, a plugin file under /wp-content/plugins/, or a theme functions.php is now different from the version that shipped. Nobody in your team uploaded anything. There was no plugin update yesterday. The site behaves normally — pages render, admin works, WooCommerce checks out — but a file on the filesystem is not the file it should be.
If you searched "wordpress file integrity monitoring free" or "wordpress detect modified core files" or you are looking for a wordpress fim alternative wordfence, you are in the right place. The standard answer is: install Wordfence, install Sucuri, install MalCare. Those work, but the paid tiers are where real-time detection lives, and the free tiers either scan on a schedule you do not control or leave the result inside the WordPress admin — which is exactly what an attacker who modified a file is trying to keep you out of.
This guide builds the same detection from logs and a cron job. The output is a wp.integrity signal every time a tracked file's hash drifts from a known-good baseline, correlated with wp.state_change events that explain legitimate drift (a plugin update) and auth.attempt events that explain malicious drift (a compromised admin).
2. Impact
A modified file in WordPress is rarely a typo. It is one of three things, and all three hurt.
- Backdoor injection. Attackers who briefly held an admin session, exploited an unpatched plugin RCE, or guessed a password drop a small PHP file (often under
wp-content/uploads/or appended to a theme file) so they can return after you patch the original hole. The injected file is webshell, eval-base64, or a credential exfiltrator. - SEO/spam payload. A modification to
wp-config.php,index.php, or a plugin bootstrap that conditionally serves spam pages to Googlebot while serving normal content to logged-in users. You will not see it. Your customers will not see it. Google will, and your rankings collapse over weeks. - Supply chain drift. A nulled plugin, a typosquat installed by mistake, or a compromised plugin update that pushed malicious code through the official update channel. The file on disk is "official" but no longer matches the canonical hash.
The cost is almost never the cleanup. The cost is the dwell time — weeks or months between the file being modified and you noticing. During that window, credentials leak, search traffic dies, and you ship customer data to whoever owns the backdoor. File integrity monitoring exists to compress dwell time from months to minutes.
3. Why It’s Hard to Spot
WordPress has no built-in file integrity check. The dashboard does not warn you when wp-load.php changes. There is a Site Health page, but it does not hash files. The official wp core verify-checksums WP-CLI command exists and works, but it only covers core, runs on demand, and prints to stdout — there is no alerting, no history, no plugin/theme coverage, and no baseline for custom code.
Hosting panels detect file changes for backups, not security. cPanel will happily back up a backdoor every night. Managed WordPress hosts (Kinsta, WP Engine) run their own scans but rarely expose per-file events to you in real time, and they explicitly exclude wp-content/uploads/ from many checks — which is exactly where webshells get dropped.
The free tier of every commercial plugin scans on a schedule (often daily, sometimes weekly) and surfaces results inside the admin UI. If the attacker disabled the plugin, deactivated the alert email, or simply modified the plugin's own scanner file, the alert never fires. Even when it does fire, the signal lives in wp_options or a custom table — accessible only to whoever can log into wp-admin.
The result is a silent failure mode: a hash on disk is wrong, nothing surfaces it, and the site keeps running. This is the classic shape of a wp.integrity event that goes unobserved for months.
4. Cause
Every PHP file that WordPress executes has a content hash. For core, that hash is published — the WordPress.org API exposes the SHA1 of every file in every release. For plugins and themes from the directory, the same is true. For your own custom code, the hash is whatever your last deploy produced.
A wp.integrity signal is emitted when the SHA256 of a file on disk no longer matches its baseline. The baseline is either:
- The official hash from
https://api.wordpress.org/core/checksums/1.0/?version=X.Y.Z&locale=en_USfor core files. - The hash captured at the moment a plugin or theme was installed/updated (the "trust on first install" baseline).
- The hash from your last known-good deploy, for custom code.
The signal payload carries: the file path, the old hash, the new hash, the file's mtime, and the user/process that last touched it (where the OS exposes that). Three facts matter:
wp.integrityis not "scan results." It is a per-file event, emitted once when drift is first observed.- It correlates against
wp.state_change— if a plugin was just updated, every file in that plugin will drift, and that drift is expected. The signal becomes interesting only when drift has no matching state change. - It correlates against
auth.attempt— drift that follows a successful admin login from a new IP or country is the highest-priority case.
This is why "free file integrity scanner" plugins miss things: they scan, they list, they leave the result in wp-admin, and they have no concept of correlation. A drift event without context is noise. A drift event correlated to "no recent update + admin login from a new ASN three hours earlier" is an incident.
5. Solution
5.1 Diagnose (logs first)
The detection pipeline is three components: a baseline, a recurring hash check, and a place to send the diff.
1. Build the baseline. Pull official checksums for your installed WordPress version and write them to disk. WP-CLI exposes the same API:
wp core verify-checksums --format=json > /var/log/wp-integrity/core-baseline.json
For plugins and themes, capture the install-time hashes:
find /var/www/html/wp-content/plugins -type f \( -name "*.php" -o -name "*.js" \) \
-exec sha256sum {} \; | sort > /var/log/wp-integrity/plugins-baseline.sha256
find /var/www/html/wp-content/themes -type f \( -name "*.php" -o -name "*.js" \) \
-exec sha256sum {} \; | sort > /var/log/wp-integrity/themes-baseline.sha256
Store these baselines outside the web root. A baseline an attacker can rewrite is not a baseline.
2. Run the recurring check. A cron job every 15 minutes is enough for most sites:
*/15 * * * * /usr/local/bin/wp-integrity-check.sh >> /var/log/wp-integrity/check.log 2>&1
The script re-hashes the same paths and diffs against the baseline:
#!/bin/bash
cd /var/www/html
find wp-content/plugins -type f \( -name "*.php" -o -name "*.js" \) \
-exec sha256sum {} \; | sort > /tmp/plugins-current.sha256
diff /var/log/wp-integrity/plugins-baseline.sha256 /tmp/plugins-current.sha256 \
| grep -E "^[<>]" \
| logger -t wp-integrity -p local0.warning
Every diff line goes to syslog tagged wp-integrity. That is the raw form of the wp.integrity signal.
3. Search the log. When something breaks or a customer reports oddness, the diagnostic question is: did any file change?
grep "wp-integrity" /var/log/syslog | tail -50
This produces wp.integrity events. Each line you see is a file whose hash drifted from baseline since the last scan. To correlate with plugin updates (which produce wp.state_change):
grep -E "(wp-integrity|plugin_activated|plugin_updated|plugin_installed)" /var/log/syslog \
| sort -k1,3
Drift events that line up with a wp.state_change for the same plugin are expected. Drift events that do not are the incident.
To correlate with admin logins:
grep -E "(wp-integrity|auth.attempt result=success)" /var/log/syslog \
| grep -E "$(date -d '6 hours ago' '+%b %_d')"
A successful auth.attempt from an unfamiliar IP followed within hours by wp.integrity events on theme functions.php or wp-config.php is the signature of a compromised admin account dropping a backdoor.
For core specifically, WP-CLI gives you a one-shot answer:
wp core verify-checksums 2>&1 | grep -i "doesn't match"
Any non-empty output here is a wp.integrity event on a core file, which should never happen on a working site.
5.2 Root Causes
(see root causes inline in 5.3 Fix)
5.3 Fix
The fix depends on which root cause produced the drift. Diagnose, then act — a wholesale "restore from backup" is the slowest option and risks restoring the original compromise vector along with the file.
- Drift on a plugin/theme file with no matching
wp.state_change. The plugin was modified outside the update channel. Re-install the plugin from the WordPress.org directory:wp plugin install. This overwrites every file with the canonical version. If the plugin came from a paid vendor, download a fresh copy from the vendor and replace the directory. Then capture a new baseline. Signal mapping: drift with no--force wp.state_changecorrelation, often preceded byauth.attempt result=successfrom an unusual source.
- Drift on a core file. Re-download core:
wp core download --force --version=X.Y.Z. Verify withwp core verify-checksumsuntil it returns clean. Core file modification almost always means a compromise — rotate every admin password, every API key inwp-config.php, salts and keys, and the database user password. Signal mapping:wp.integrityon/wp-includes/or/wp-admin/paths produces a high-severity event because core never legitimately drifts between updates.
- Drift on
wp-config.php. Diff the current file against the last known-good copy stored outside the web root. Look for addedeval(),base64_decode(), or appended PHP at the bottom of the file. Restore from the offsite copy and rotate all secrets in it. Signal mapping: a single high-prioritywp.integrityevent —wp-config.phpshould change only when you touch it.
- New PHP files under
/wp-content/uploads/. WordPress should never write executable PHP into uploads. The presence of a.phpfile in any uploads subdirectory is itself awp.integrity-class event (file appeared with no baseline). Delete the file, then add a server-level rule (Options -ExecCGIor nginxlocation ~ /uploads/.\.php$ { deny all; }) so PHP cannot execute from uploads even if dropped again. Signal mapping: new-file-in-watched-path is the same signal class — drift from the baseline of "no PHP files exist here."
- Drift on themes that match a plugin update. This is your daily false positive. After a plugin or theme update completes, every file in that plugin will drift. Re-baseline the plugin directory immediately after legitimate updates, automated as a post-update hook. Signal mapping:
wp.integrityevents that pair 1:1 with awp.state_changefor the same component within a 60-second window are auto-suppressed in a healthy pipeline.
- Drift you cannot explain at all. When in doubt, treat it as a compromise. Pull the site offline, restore from a backup that predates the earliest unexplained
wp.integrityevent, patch the entry point (almost always an outdated plugin), rotate secrets, and re-baseline.
5.4 Verify
The fix is verified when wp.integrity events stop appearing on the affected paths and wp core verify-checksums returns clean.
Concrete checks:
- Re-run the integrity script manually:
/usr/local/bin/wp-integrity-check.shshould produce no diff output. wp core verify-checksumsshould exit 0 with no "doesn't match" lines.grep "wp-integrity" /var/log/syslog | grep "$(date '+%b %_d')"should show no new entries since the fix was applied.
A healthy site emits zero wp.integrity events for at least one full scan cycle (15 minutes) after the fix, except where new entries pair with a deliberate wp.state_change (a plugin you intentionally updated). If 24 hours pass with the only wp.integrity events being correlated to known updates, the file system is clean.
If new uncorrelated wp.integrity events appear after cleanup, the entry point is still open. The drift is not the compromise — it is the symptom. Stop, find the vulnerable plugin or stolen credential, and close that first.
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.integrity.
The unfixable thing about file integrity is that you cannot prevent the change. An attacker with valid credentials, a plugin RCE, or filesystem access will modify files. What you can prevent is the months-long dwell between modification and discovery.
The free-plugin approach scans daily, surfaces results in the admin UI an attacker can disable, and has no concept of correlation. That is not file integrity monitoring. That is a checkbox.
A log-driven approach is different. The wp.integrity signal is a per-file event written outside WordPress, outside wp-admin, and outside anything an attacker holding admin can suppress. It correlates automatically against wp.state_change (so plugin updates are not noise) and against auth.attempt (so a successful admin login from a new ASN followed by drift is one alert, not two unconnected facts).
This is the detection Logystera ships. It ingests wp.integrity from the WordPress agent or plugin endpoint, joins it against wp.state_change and auth.attempt from the same site, and emits a single correlated alert when drift has no legitimate explanation. No wp-admin access required. No paid plugin tier. The detection runs in your log pipeline, not on the host the attacker is trying to keep you out of.
7. Related Silent Failures
- Successful admin login from a new country (
auth.attemptcluster). Almost every maliciouswp.integrityevent has a recent successful login behind it. Watching admin auth from new ASNs catches the compromise hours before the file modification. - Plugin silently activated (
wp.state_changecluster). An attacker dropping a backdoor often disguises it as a new plugin. Awp.state_change type=plugin_activatedfor a slug that does not exist in the WordPress.org directory is the same class of failure as direct file drift. - REST API user enumeration (
auth.attemptcluster). A burst ofGET /wp-json/wp/v2/usersrequests is reconnaissance. It precedes credential stuffing, which precedes admin compromise, which precedeswp.integrity. - PHP files appearing under
/wp-content/uploads/. The same signal class as drift — baseline says "no PHP here," reality says otherwise. Most webshells live exactly there. wp-config.phpmodification. A high-severity case ofwp.integritydeserving its own alert. The file holds database credentials and salts; modification means either a deploy you forgot or a compromise you have not caught yet.
See what's actually happening in your WordPress system
Connect your site. Logystera starts monitoring within minutes.