How to Stop Fake User Registrations and Comment Spam in WordPress
Hundreds of fake subscribers or a comment queue full of junk? Close every registration door, bulk-remove fake users safely and lock down comments.
You cleaned the site and the malware came back. Something survived: a backdoor, hidden admin, cron job, stolen password or unpatched plugin. Find it and prove the site is clean.
You removed the malware, the scanner came back clean, and a few days later the spam pages, the redirect or the strange admin user are back. Some people go through this cycle for months, paying for cleanups that each last a week. The malware is not regenerating by magic. Something from the original attack survived, and it is either reinstalling the payload or letting the attacker back in to do it again.
Attackers plan for cleanup. Alongside the visible damage they leave several quiet ways back, called persistence mechanisms, in places that scanners and quick cleanups miss. This guide walks through every one worth checking on a WordPress site, then gives you a step-by-step method to prove the site is clean and keep it that way. The first cleanup routine is in FG-05; this guide assumes you have done it at least once.
Reinfection comes from one of two things: the attacker still has a way in, or something on the server reinstalls the malware by itself. Many sites have both.
| Mechanism | Where it hides | Why cleanups miss it |
|---|---|---|
| Unpatched vulnerability | An outdated or abandoned plugin or theme | The malware is removed but the hole is not |
| Stolen credentials | SFTP, hosting panel, database, mailbox, WordPress application passwords | Only the WordPress admin password was changed |
| Hidden admin user | The users table, hidden from the Users screen by code | The admin screen shows only “real” users |
| Hidden or must-use plugin | wp-content/mu-plugins, fake plugin folders, drop-ins |
Not shown, or filtered out, in the Plugins screen |
| Scheduled reinstaller | WP-Cron events, the server crontab, a background process | Runs hours or days after cleanup |
| Loader outside WordPress | wp-config.php, root index.php, .user.ini, php.ini, .htaccess |
Scanners focus on plugin and theme folders |
| Payload in the database | Options, widgets, post content | File scans do not read the database |
| Neighbor site | Another site in the same hosting account | Only the site that showed symptoms was cleaned |
wp-config.php, unless a step says otherwise. Some hosts call WP-CLI wp-cli. Run wp db prefix once and, in every SQL query below, replace wp_ with the prefix it prints. Words in CAPITALS are placeholders explained under each command.The most common reason for reinfection is that the hole that let the attacker in is still open. Automated attack tools rescan the web constantly, so an unpatched site gets reinfected even if the original attacker has moved on.
wp plugin list --fields=name,status,version,update_version
wp theme list --fields=name,status,version,update_version
Type both as shown. --fields chooses the columns: the slug, whether it is active, the installed version and the newest available version. A value in update_version means an update is waiting. Check every plugin and theme, active or not (inactive code can still be attacked directly), against a public vulnerability database such as WPScan, Wordfence Intelligence or Patchstack. Remove anything with a known unpatched vulnerability, anything closed on WordPress.org, anything not updated in years, and every nulled (pirated) premium plugin, which often ships with a backdoor built in.
FG-05 covers rotating hosting, SFTP, database and email passwords and shuffling salts. Two WordPress-specific items are often missed:
wp user application-password list USER_ID
wp user session destroy --all
Replace USER_ID with the ID of each administrator (from wp user list --role=administrator). Application passwords let programs sign in to the REST API without the normal password or two-factor code, and changing the main password does not revoke them. The first command lists them with their name, creation date and last use; any you did not create should be deleted with wp user application-password delete USER_ID UUID, replacing UUID with the value in its uuid column. The second command signs every user out of every device by deleting their login sessions. Good output for the second ends with a Success line.
ls -la ~/.ssh/ 2>/dev/null
cat ~/.ssh/authorized_keys 2>/dev/null
Type both as shown. The first lists the hidden .ssh folder in your home folder, the second prints every public key allowed to log in as this user, one per line, with a label at the end. 2>/dev/null hides the error if the file does not exist. Remove any key you cannot match to a person or computer you trust, and check your hosting panel’s SSH key and FTP account screens for extra entries. If your FTP client stores passwords, and the computer it runs on has had malware, assume those passwords were stolen; password-stealing malware on a designer’s computer is a well-known route to repeat infections across every client site they manage.
Malicious code can hook into WordPress’s user queries and remove its own account from the Users screen and the user count. WP-CLI loads plugins too, so it can be fooled the same way. Ask the database directly:
wp db query "SELECT u.ID, u.user_login, u.user_email, u.user_registered FROM wp_users u JOIN wp_usermeta m ON m.user_id = u.ID WHERE m.meta_key = 'wp_capabilities' AND m.meta_value LIKE '%administrator%';"
Replace both wp_ prefixes in the table names and the one in 'wp_capabilities' with your prefix. The query joins the users table to the user meta table and lists every account whose stored role includes administrator, with its login, email and registration date. Compare the result with wp user list --role=administrator. If the SQL shows an account that WP-CLI and the Users screen do not, code is hiding it: that is both a rogue admin and proof that a hiding plugin or snippet is active. Delete the account (FG-05 shows how), then find the code that hid it, starting with Step 3.
A malicious plugin can remove itself from the plugin list with a filter. Compare what is on disk with what WordPress reports:
ls -1 wp-content/plugins
wp plugin list --field=name --skip-plugins --skip-themes
Type both as shown. The first lists every folder and file in the plugins directory, one per line. The second lists the plugins WordPress knows about; --field=name prints only slugs, and --skip-plugins --skip-themes stops regular plugins and the theme from loading, so they cannot filter the list (a must-use plugin still can, which is why the folder comparison matters). Any folder in the first list but not the second, other than a lone index.php, deserves a look. So does a plugin with a generic name (such as “WP Core Helper” or “Security Tools”) that you never installed.
Must-use plugins in wp-content/mu-plugins load automatically on every request, cannot be deactivated from the dashboard, and still load when you run WP-CLI with --skip-plugins. Drop-ins are specially named files directly in wp-content that replace parts of WordPress.
ls -la wp-content/mu-plugins/ 2>/dev/null
wp plugin list --status=must-use
wp plugin list --status=dropin
Type them as shown. The first lists everything in the must-use folder, including hidden files starting with a dot. The second and third show what WordPress recognizes as must-use plugins and drop-ins. Legitimate examples come from your host (management or caching helpers) and from plugins you use. Recognized drop-in names include advanced-cache.php and object-cache.php (caching plugins), db.php (database plugins such as Query Monitor), db-error.php, maintenance.php, php-error.php, fatal-error-handler.php and, on multisite, sunrise.php and the blog-*.php pages. Open each file you cannot attribute to something you installed. A drop-in with no caching or database plugin to explain it is a red flag.
wp cron event list --fields=hook,next_run_relative,recurrence
crontab -l
Type both as shown. The first lists every WP-Cron event with when it runs next and how often it repeats. WordPress core hooks start with wp_ (such as wp_version_check); plugins usually prefix hooks with their own name. For a hook you cannot place, find what registers it with grep -rn "HOOK_NAME" wp-content, replacing HOOK_NAME with the name from the list; the file that matches is your suspect. Remove the event with wp cron event delete HOOK_NAME only after removing that code, or it will be rescheduled. The second command prints the server crontab for your user. Each line is a schedule (five time fields) and a command. Lines that download files with curl or wget, or run PHP files from odd places, are malicious; your hosting panel’s cron screen shows the same list.
Some malware runs as a background process that rewrites files seconds after you delete them:
ps -u "$(whoami)" -o pid,etime,args
Type it as shown. It lists processes owned by your user with their process ID, how long they have been running and their full command line. Short-lived PHP processes serving web requests are normal. A PHP or Perl process that has been running for hours or days, especially one started from /tmp or with an unreadable command name, is not. Note its details for your host, then stop it with kill PID, replacing PID with the number in the first column. On shared hosting you may not see web processes at all; ask your host.
grep -nE "include|require|eval|base64|@ini_set" wp-config.php index.php
Type it as shown. -n prints line numbers and -E enables the pattern. In a standard install, wp-config.php should match only on its final require_once line loading wp-settings.php, plus anything your host or a plugin documented (such as a WAF loader). The root index.php should match only on the line requiring wp-blog-header.php. Any other include pointing to a file in uploads, a hidden file, or a path with odd characters is a loader: note the file it loads, remove the line, and remove the file.
PHP’s auto_prepend_file setting runs a chosen file before every PHP script on the site, which makes it a favorite hiding place. It can be set in a .user.ini or php.ini file in the site folder or any parent folder, or in .htaccess on servers running PHP as an Apache module.
find ~ -maxdepth 4 \( -name ".user.ini" -o -name "php.ini" -o -name ".htaccess" \) -exec grep -Hn "auto_prepend_file\|auto_append_file" {} \; 2>/dev/null
Type it as shown. It searches your home folder, four levels deep, for the three kinds of file, and prints each line that sets auto_prepend_file or its twin auto_append_file, with the file name and line number (-H and -n). A known legitimate match is Wordfence’s firewall, which prepends a file called wordfence-waf.php in the site root. Anything else, especially a path into uploads or a file with a random name, is malicious. After removing it, allow a few minutes: PHP caches .user.ini settings, by default for 300 seconds.
Run wp core verify-checksums --include-root. Anything listed as modified, or as a file that should not exist inside wp-admin or wp-includes, means core was altered; replace core entirely with a fresh download rather than editing files. --include-root also flags unknown files in the site root, a common place for loaders.
Code stored in the database is harmless on its own until something executes it, which is why database payloads usually pair with a small loader file. Clean both. Search for the usual encodings:
wp db search "eval\(|base64_decode|gzinflate|str_rot13|fromCharCode|atob\(" --regex --all-tables --stats
Type it as shown. --regex treats the search as a regular expression (| means “or”), --all-tables includes plugin tables, and --stats summarizes matches per table. PHP payloads usually sit in an option with a bland or random name; JavaScript payloads in widgets, posts and theme settings. FG-23 covers reading and safely removing JavaScript injections. For a malicious option, delete it with wp option delete OPTION_NAME, replacing OPTION_NAME with the name shown, after finding and removing the code that reads it.
If several sites share one hosting account, they usually share one system user, and malware in any of them can rewrite the others. Cleaning only the site with symptoms is one of the most common reasons infections return. FG-25 includes a loop that runs checksum checks and an uploads scan across every install in an account; run it, and remove any old or test install you do not need.
If you cannot find the mechanism, make the next reinfection tell you where it came from. Match the reinfected file’s change time to your logs:
stat -c "%n | changed %z" FILE_PATH
grep "DD/MMM/YYYY:HH:MM" ACCESS_LOG
Replace FILE_PATH with the reinfected file. %z is the change time, which records when the file was actually written. Then replace DD/MMM/YYYY:HH:MM with that time in the access log’s format: two-digit day, three-letter month as in Nov, four-digit year, then hour and minute and ACCESS_LOG with your log path. Requests in that minute show what triggered the write. A POST to an unknown file is a backdoor; a request to wp-cron.php points to a cron event; no web request at all points to a server cron job, a background process, or an SFTP login (check your host’s FTP log for that time).
On a VPS you can watch writes live with the inotifywait tool from the inotify-tools package:
inotifywait -m -r -e create,modify,moved_to --timefmt "%F %T" --format "%T %w%f %e" wp-content
-m keeps watching instead of exiting after one event, -r watches every subfolder, -e picks the events (new files, changed files, files moved in), and --format prints the time, path and event. Leave it running in a terminal multiplexer such as screen. Normal output shows cache files and uploads; a PHP file appearing in uploads or a theme file changing at 3am is your culprit caught with a timestamp.
Once clean, record a fingerprint of every code file so any future change is obvious:
find . -type f \( -name "*.php" -o -name "*.js" -o -name ".htaccess" -o -name ".user.ini" \) -not -path "./wp-content/cache/*" -exec sha256sum {} + > ~/baseline.sha256
sha256sum -c --quiet ~/baseline.sha256
Run the first line from the site folder once, right after cleanup and after every legitimate update. It hashes every PHP, JavaScript, .htaccess and .user.ini file and saves the list in your home folder, outside the website, where visitors cannot download it. The second line, run later from the same folder, rechecks every file: --quiet prints only files that changed or disappeared, so no output means nothing changed. It cannot spot new files; pair it with find . -name "*.php" -newer ~/baseline.sha256, which lists PHP files created or changed since the baseline was written. Add both to a daily cron job that emails you the output, or use a security plugin or host feature with file change alerts.
wp core verify-checksums --include-root and wp plugin verify-checksums --all pass.wp-config.php or index.php, and no unknown auto_prepend_file.POST requests to unfamiliar files.If every box is checked and the malware still returns, the foothold is outside your reach: a server-level compromise, or shared credentials elsewhere.
Open a ticket once you have ruled out everything you can see, or straight away if you find files owned by another user, processes you cannot stop, or loaders in parent folders above your site. Send:
.user.ini files or SSH keys.Ask them to check FTP and panel login logs for those times, server-level cron and processes, and whether other accounts on the server are affected.
Did this fix it?
Still stuck? Start again from what you can see in the Emergency Room.
Still stuck?
Print a free one-page checklist to keep, or open the Emergency Room if the symptom does not match.
Get the free checklistsKeep reading
Search the field manual
Or jump straight to a symptom
Press / to search from any page, Esc to close