Skip to content

Why your WordPress site keeps getting hacked, and what finally stops it

A WordPress site that is reinfected was either never fully cleaned or is still open the way it was first entered. There are eight reasons, and each one can be checked. A rebuild from clean sources, then new credentials, then updates, deals with them in order.

By
WP Ministry
Published

In short

  • A site that is reinfected was never fully cleaned, or is still open the way it was first entered. Check for both.
  • Deleting the files a scanner flags leaves backdoors, scheduled tasks, database content and stolen credentials where they were.
  • Rebuild instead of hunting. Download WordPress, every plugin and every theme again from its source, and carry over only media you have checked.
  • Change every credential and the secret keys after the rebuild, from a computer you have scanned.
  • Every WordPress on the same hosting account needs the same cleanup at the same time.
  • For the first weeks, watch for changed files, new administrators and Google's Security issues report.

A WordPress site that is infected again after a cleanup was either never fully cleaned, or is still open the way it was first entered. Google's guidance for hacked sites says an attacker may leave a back door that lets them return, or that puts back the code you removed. The reasons come down to eight, and each one can be checked.

This page is for when a first cleanup was not enough. For the first one, use how to remove malware from a hacked WordPress site.

Why a cleaned site is infected again

The reasonWhy a cleanup misses itWhere to check
A backdoor was left behindIt need not look like malware, so a scan can pass itmu-plugins, drop-ins, uploads, WordPress's own folders
The backup was already infected, or never updatedThe break-in is older than the day you noticedThe backup's date against the earliest sign
The vulnerable plugin or theme is still installedCleaning removes what was planted and leaves the holeThe plugin list, with each plugin's status on WordPress.org
The attacker still holds a credentialNot every password was changed, or too earlyUsers, application passwords, SFTP, the control panel, the database, the secret keys
An administrator or a scheduled task puts it backIt is outside the site's filesUsers, scheduled events, the crontab
Another site in the hosting account is infectedOnly one site was cleanedEvery WordPress under the account
The infection is in the databaseA file cleanup never touches itAddress settings, posts, widgets, stored code
Your own computer, or a reused passwordThe site was never the weak pointEvery computer used to log in

More than one can be true at once. Google's guidance says there may be several independent hacks in place, and recommends that you keep looking after you find the first.

A backdoor was left behind

A backdoor is a file, or a few lines inside a real file, that lets the attacker back in without the original hole. Removing what a scanner flags removes only what that scanner recognized. Look where code runs without anyone activating it.

  • wp-content/mu-plugins. WordPress loads every PHP file directly in this folder, before the ordinary plugins. These files cannot be disabled from the dashboard, and the Plugins screen shows them only in a separate "Must-Use" section.
  • Drop-ins. WordPress loads a few files by name when they sit directly in wp-content: object-cache.php, db.php, db-error.php, maintenance.php, php-error.php, fatal-error-handler.php, install.php, and advanced-cache.php when WP_CACHE is on. A caching plugin may own one.
  • Files named like WordPress's own, added to wp-admin or wp-includes. The checksum command warns about each file that should not exist.
  • .htaccess and .user.ini in any folder. Apache applies an .htaccess file to its folder and every folder under it. PHP running as CGI or FastCGI reads a .user.ini in the requested file's folder and each folder above it, and its auto_prepend_file setting names a file to run ahead of the requested one.
  • The uploads folder. The cleanup below has the command.
bash
ls -la wp-content/mu-plugins/ wp-content/*.php
find . -type f \( -name ".htaccess" -o -name ".user.ini" \)
wp core verify-checksums --include-root

Use ls and not wp for the first, because wp would run those files. If there is no mu-plugins folder, ls says so and lists the rest. Apart from the drop-ins, the only PHP file WordPress ships directly in wp-content is a small index.php. --include-root also warns about files in the main folder that are not part of WordPress. Some will be yours. Open whatever you cannot account for.

What fixes it. Finding one backdoor does not show there is no second. The cleanup below builds the site again in an empty folder, so a planted file has nowhere to stay.

The backup you restored was already infected

Google's guidance says to check that a backup was made before the site was hacked, which is not the same as before the day you noticed. WordPress's hardening guide gives the example of a site compromised on the first of the month and not detected until the twelfth.

Compare the backup's date with the earliest sign you can find: the oldest file the attacker added, or the registration date of an administrator nobody created. Then run the checks on this page on the restored site, as if it had never been cleaned.

A clean backup also restores the hole: the same out-of-date plugin, the old passwords and the old secret keys.

What fixes it. Go further back, or rebuild as described below. After either, Google's guidance is to upgrade all the software, remove what is not used and change every password.

The vulnerable plugin or theme is still installed

Cleaning removes what the attacker planted and leaves the hole it came through. WordPress's hardening guide points out that once a fix is released, what is needed to exploit the hole is almost certainly public.

bash
wp plugin list --fields=name,status,version,update,wporg_status --skip-plugins --skip-themes
  • update says whether a newer version is waiting.
  • wporg_status should read active. closed is not proof by itself: the column can also show it for a plugin that was never on WordPress.org, such as a paid or custom one. For anything but active, open the plugin's page on WordPress.org. A closed plugin's page says it is no longer available for download. A security issue is one of the listed reasons for closing one, and the reason is shown only after 60 days.
  • The plugin's own page shows when it was last updated. It warns when it has not been tested with the three latest major releases of WordPress, and says it may no longer be maintained.

"Nulled" software is a paid plugin or theme obtained from somewhere other than its author. WordPress's hardening guide says not to get plugins or themes from untrusted sources, and to keep to the WordPress.org repository or well-known companies. Google's guidance gives the reason: adding malicious code to free versions of paid plugins and themes is a common tactic. Reinstalling such a copy reinstalls whatever was added to it.

What fixes it. Update what has an update. Delete what you do not use, as the hardening guide says. Replace a closed or abandoned plugin with a maintained one, and a nulled copy with one from the author. How to audit a WordPress site for security weaknesses shows how to look up each plugin's known vulnerabilities.

The attacker still holds a credential

A password changed while the attacker still had a way in was changed in front of them. WordPress's documentation says to change the passwords again once the site is clean, for every user with access, and lists the access points: FTP or SFTP, the dashboard, the hosting control panel and the database.

The credentialWhere it is changedWhat is easy to miss
WordPress users who can log inEach user's profile, or wp user reset-passwordCheck the email address on each account first. A reset is emailed there.
Application passwordsThe "Application Passwords" section of each user's profileThey work for the REST API, and for XML-RPC where it is enabled, and not on the login page. Each is revoked on its own.
Sessions already logged inThe eight secret keys in wp-config.phpChanging the keys invalidates every existing login cookie, the attacker's included.
SFTP, FTP and SSH accountsYour host's control panelDelete accounts nobody uses.
The hosting control panelYour hostIt is on WordPress's list, and easy to forget.
The database passwordYour host's control panel, then DB_PASSWORD in wp-config.phpAnyone who could read the site's files could read it.

List the application passwords on each administrator's account, with the user's ID in place of 1. last_used and last_ip show whether one is in use and from where. created and last_used are printed as Unix timestamps, and date -u -d @1791390665 turns one into a date. wp user application-password delete 1 --all removes all of that user's.

bash
wp user application-password list 1 --fields=uuid,name,created,last_used,last_ip --skip-plugins --skip-themes

wp config shuffle-salts replaces the secret keys. Without WP-CLI, the salt generator makes a fresh set to paste over the eight lines.

What fixes it. Change all of them in one sitting, after the rebuild and from a computer you have scanned. Then turn on two-factor authentication for every administrator.

An administrator or a scheduled task puts it back

An administrator account, a scheduled event in WordPress and a job in the server's own scheduler all sit outside the files a cleanup replaces.

bash
wp user list --role=administrator --fields=ID,user_login,user_email,user_registered --skip-plugins --skip-themes
wp cron event list --skip-plugins --skip-themes
crontab -l
  • Administrators. Every name should be a person you can account for. Then run the list once more without --role=administrator and with roles added to --fields, and account for every account above Subscriber. A capability granted to one account directly does not show in that column. What to do about an administrator nobody created has the database query that shows it. Deleting an unknown one is not enough by itself, because whatever created the account is still there.
  • Scheduled events. Each row is a hook and the time it next runs. wp_version_check, wp_update_plugins and wp_update_themes are WordPress's own. WordPress's documentation notes that a scheduled task goes on being attempted even after the plugin that set it is removed. For a hook you do not recognize, find the file that names it with grep -rl "hook_name" wp-content. If nothing you installed accounts for the file, remove it, then the event, with wp cron event delete hook_name.
  • The crontab. Each user on a server can have a table of scheduled commands, kept outside the site's folder. crontab -l prints yours and crontab -e edits it. A line you did not add comes out, above all one that fetches from another address or runs a PHP file.

Another site in the same hosting account

WordPress's hardening guide says that on a shared server, a compromised site can lead to yours being compromised even if you follow everything in the guide.

File permissions do not protect sites under one account from each other. A permission mode sets what a file's owner, its group and everyone else may do. WordPress's documentation describes the setup many shared hosts use, in which PHP runs as the owner of the PHP files. Every site in one account has the same owner. So code running in one site is the owner of the other site's files, and the 644 and 755 modes that keep strangers out give the owner write access. The file permissions guide explains the digits.

bash
find ~ -type f -name "wp-config.php"

Each line is a WordPress installation: the live sites, and also an old staging copy or a test site nobody remembers.

What fixes it. Give every site on the account the same cleanup at the same time, and delete the ones nobody uses. Ask your host whether each site can have an account of its own. The hardening guide also suggests a separate database and database user for each site.

The infection is in the database

Replacing every file leaves the database as it was. Google's guidance describes an attacker inserting malicious code into every record of a database table.

bash
wp option get siteurl --skip-plugins --skip-themes
wp option get home --skip-plugins --skip-themes
wp db search "<script" --skip-plugins --skip-themes

The first two should print your own address and nothing else. The third searches every text column of the tables WordPress has registered and prints the table, the column, the row and the text around each match. Expect some matches of your own, such as an embed in a post. Search as well for any domain that appeared in the injected content. Add --all-tables-with-prefix to include tables that plugins created.

Read every snippet in any plugin that stores code in the database and runs it. The hardening guide warns that such plugins magnify the damage of a successful attack.

What fixes it. Remove injected content through the screen that owns it, such as the post editor or the widgets screen. Restoring the database from before the break-in also works, and loses everything added since.

The computer or the password on your side

WordPress's documentation says that in many instances the attack begins on the owner's own computer, where malware captures the logins for FTP and the dashboard. A password changed on that computer can be captured as it is typed. A reused password fails the same way: Google's guidance notes that attackers who find a working username and password try the pair on as many services as they can.

What fixes it. Google recommends running several reputable antivirus scanners on every computer an administrator has used to log in. Do that before changing any password, then give every account a password used nowhere else.

Find the way in from the access log

The access log records the requests the server handled. Read around the time files changed, and it can show which request put them there. Ask your host for it early, because it reaches back only as far as the host keeps it.

  1. Step 1: Find when files changed

    The first command lists PHP files whose contents changed in the last 14 days, each with its date. Look for files changed within the same minute on a day when nobody updated anything. The touch command can set a modification time to any value, so the second command reads the status-change time instead, which the system sets itself whenever a file is written or its attributes change. A file on the second list only was altered after the date it shows. A restore or a change of permissions does that too.

    bash
    find . -type f -name "*.php" -mtime -14 -ls
    find . -type f -name "*.php" -ctime -14 -ls
  2. Step 2: Pull the log lines for that time

    In Apache's log format each line holds the visitor's address, the time in square brackets, the request in quotes and the status code. This prints every request from 14:30 to 14:39 on September 21, 2026. The stamp ends with the log's time zone, so check that it matches the zone of the file dates.

    bash
    grep "21/Sep/2026:14:3" access.log
  3. Step 3: Look for POST requests to files that should not receive them

    A POST sends data to the server. WordPress takes its forms at addresses of its own, such as the login page and wp-admin/admin-ajax.php. Uploads holds media, and the hardening guide describes the scripts in wp-includes as not intended to be accessed by any user. This lists every POST sent straight to a PHP file inside wp-content or wp-includes. Some plugins do take requests at their own files, so each line needs explaining and is not proof by itself.

    bash
    grep -E '"POST [^ ]*/(wp-content|wp-includes)/[^ ]*\.php' access.log
  4. Step 4: Follow one address back

    Print everything a suspect address requested, with that address in place of 203.0.113.5. Read upward from its first request to the planted file. The requests just before it are the candidates for the way in, and a request to a plugin's file names the plugin to look up.

    bash
    grep "^203.0.113.5 " access.log

The log can show which file was requested, when, from which address, and whether the request succeeded. It cannot show:

  • What was sent. The standard formats record the request line, the status and the size, not the body of a POST.
  • Who it was. The hardening guide notes that logs do not record which username logged in, and Apache's documentation adds that the address may be a proxy's. A login with a stolen password looks like any other.
  • Anything that did not come through the website, such as a file placed over SFTP.
  • That it is complete. Google's guidance warns that the attacker may have altered the logs.

If the log does not settle it, treat all eight reasons as open.

A cleanup that holds

Hunting for infected files one at a time is what failed the first time. This cleanup builds the site again from sources you can trust and carries over three things only: wp-config.php after you have read it, the media after you have checked it, and the database after you have searched it.

Before you start, take the site offline and keep a copy of it as it is, as the hacked site runbook describes. Scan the computers you work from. Do every WordPress on the hosting account in the same sitting. In the commands the old folder is public_html and the new one clean. Use your own folder's name.

  1. Step 1: Download WordPress into a new, empty folder

    First save the list of what the site runs, with wp plugin list --skip-plugins --skip-themes and the same for wp theme list. The commands below print the WordPress version, make a folder beside the old one, download that version from WordPress.org with the number in place of 1.2.3, check the download against the checksum WordPress.org publishes for it, and unpack it into the new folder. The fourth command must answer OK before you go on. WordPress's documentation says to reinstall the same version, and warns that reinstalling from the dashboard often only overwrites existing files, while hacks add new ones. Then run cd ../clean, and run every later command there.

    bash
    wp core version
    mkdir ../clean
    curl -fsSL -o ~/wordpress-1.2.3.tar.gz https://wordpress.org/wordpress-1.2.3.tar.gz
    echo "$(curl -fsSL https://wordpress.org/wordpress-1.2.3.tar.gz.sha1)  $HOME/wordpress-1.2.3.tar.gz" | sha1sum -c -
    tar -xzf ~/wordpress-1.2.3.tar.gz --strip-components=1 -C ../clean
  2. Step 2: Bring wp-config.php across by reading it

    Open the old wp-config.php beside wp-config-sample.php from the new download. Yours should differ only in the database details, the keys, the table prefix and settings that you, your host or a plugin added. Trace any line that loads another file. Copy it across when every line is accounted for.

  3. Step 3: Reinstall every plugin and theme from its source

    The command installs a plugin from WordPress.org by its folder name, and wp theme install does the same for a theme. --force is needed: the new folder shares the old database, which still lists the plugin, and without it WP-CLI answers "Plugin already installed" and installs nothing. A paid plugin or theme comes from its author's own download page, and a custom theme from your own copy, such as the developer's repository. Leave out whatever you do not use or cannot trace to an author, and delete the default themes and plugins the download brought that you will not use. Do not copy mu-plugins or a drop-in. If your host or a caching plugin needs one, get it from them again.

    bash
    wp plugin install plugin-folder-name --version=1.2.3 --force --skip-plugins --skip-themes
  4. Step 4: Check the new files against their checksums

    These compare WordPress's own files, then each plugin's, with the checksums WordPress.org holds. They do not cover themes, plugins from outside WordPress.org, uploads or the database. A clean run prints the Success line and nothing else. Any Warning: line is a finding, even when the last line says Success: a file that was added, with no original changed, is reported as a warning and the command still ends in Success.

    bash
    wp core verify-checksums --include-root
    wp plugin verify-checksums --all --skip-plugins --skip-themes
  5. Step 5: Check uploads, then carry over the media and nothing else

    This lists every file in the old uploads folder whose name contains .ph, with any .htaccess or .user.ini. The search is wide because PHP's manual shows that a server can be set to run other extensions, such as .phtml, and warns about names like exploit.php.jpg. The search also matches ordinary media with .ph in its name, such as team.photo.png, and an .htaccess a plugin wrote to protect a folder of its own. Open each file, and delete from the old folder every one that is not media you put there. When every file left on the list is one you have opened and recognize, copy the folder with cp -a ../public_html/wp-content/uploads wp-content/, which keeps dates and permissions.

    bash
    find ../public_html/wp-content/uploads -type f \( -iname "*.ph*" -o -name ".htaccess" -o -name ".user.ini" \)
  6. Step 6: Stop PHP from running in uploads

    Add the rule shown under these steps. For the site's main .htaccess, write a new file holding only the standard block WordPress writes, shown in how to fix the 403 Forbidden error, and add back only rules you know you wrote.

  7. Step 7: Check the database

    The database is the one part that is not new. Run the checks from the sections above: the administrators, the application passwords, the scheduled events, the two address settings and the search.

  8. Step 8: Put the new folder in place

    Rename the old folder, then give the new folder its name. The old one should end up outside the folder your host serves, so that nothing in it can be requested. Delete it from the server once you hold a copy elsewhere.

  9. Step 9: Change every credential and the secret keys

    Do this now, when nothing of the attacker's is left to read the new values, and from a computer you have scanned. Go down the credentials table, the database password included. Then replace the keys, which logs everyone out.

    bash
    wp config shuffle-salts
  10. Step 10: Update everything

    The site was rebuilt at the versions it ran, which may include the one that was broken into. Update before the site goes back online. How to safely update WordPress has the order.

    bash
    wp core update
    wp plugin update --all
    wp theme update --all

The rule that stops PHP in uploads

The nginx rule is the one in WordPress.org's nginx documentation. The Apache rule uses two directives from Apache's documentation: FilesMatch, which may be used in .htaccess, and Require all denied.

wp-content/uploads/.htaccess

<FilesMatch "\.(php[0-9]?|phtml|phar)$">
    Require all denied
</FilesMatch>
Use the one that matches your server.

The Apache rule works only where the host lets .htaccess files set access rules. The nginx rule goes in the site's server block, above the block that hands .php files to PHP, because nginx checks regular expressions in order and stops at the first match. On managed hosting, ask the host to add it. Either rule stops a browser from requesting those files, and does not stop WordPress from reading a file a plugin keeps there. To test, put an empty test.php in uploads, request it, expect a 403 error, and delete it.

What to watch in the first weeks

WordPress's hardening guide recommends monitoring a site's files for changed and added files. When the cleanup is finished, and again after each update you run yourself, make a marker with touch ../clean-marker. Then check every few days at first, and weekly after that:

bash
find . -type f \( -name "*.php" -o -name ".htaccess" -o -name ".user.ini" \) -newer ../clean-marker
wp core verify-checksums --include-root
wp plugin verify-checksums --all --skip-plugins --skip-themes
  • Changed files. The first command lists every PHP and configuration file changed since the marker. With no update in between, it should print nothing. The checksum commands compare contents, so a changed file is reported even when its date was set by hand. Read every line they print, not only the last: an added file appears as Warning: File should not exist above a line that still says Success.
  • New users. Run the administrator list again, and look at each administrator's application passwords.
  • Scheduled events and the crontab. One that has returned means the code that sets it is still on the server.
  • Google's Security issues report. In Search Console it lists what Google has found on the site. If the site was flagged, select "Request Review" only once the whole site is clean. The hacked-site check reads the site from outside and reports signs, not a verdict.

If something comes back, write down the first file that changed and its time before you remove it. That time, taken to the access log, can show which of the eight reasons was missed.

After several quiet weeks, move on to prevention. The WordPress security guide sets out what to tighten, in order, and WordPress firewalls compared covers what a firewall can and cannot filter.

When to hand it over

Get help when:

  • the infection returns after a rebuild like the one above;
  • the site runs a custom theme or plugin and you have no clean original of it;
  • you cannot get SSH access, the access log or a list of the other sites on the account;
  • the site holds customer accounts, orders or payment details.

Our malware removal service is a one-time clean-up with hardening, a blacklist removal request and a written report, and if the infection comes back within 30 days we clean the site again. Scanning, updates and backups from then on are what a care plan does, described under WordPress security.

Common questions

Why does the malware come back after I delete the infected files?

The files you deleted were the result and not the cause. Something put them there: a backdoor in another file, a scheduled task, a stolen password, or the same hole in a plugin that was used the first time. Until that is found or rebuilt away, the files can be put back.

Will a security plugin or a firewall stop it coming back?

Not by itself. A scanner reports what it was built to recognize, and WordPress's documentation says no single one is the best approach. A firewall filters requests and does not clean a site that is already infected. How to check whether your WordPress site has been hacked sets out what each kind of check covers.

Will moving to another host fix it?

Only if the cause was on the old host, such as another infected site in the same account. WordPress's hardening guide says break-ins are rarely down to the host's infrastructure and most often down to the application, which is the part you look after. Copying the site to a new host copies its files and its database, and any backdoor in them.

Can I do this without SSH or WP-CLI?

Partly. Your host's file manager shows mu-plugins, the files in wp-content and the uploads folder, and the dashboard shows users and their application passwords. The rebuild can be done over SFTP with downloads from WordPress.org and from each plugin's author. For the checksums, the scheduled events and the database search, send your host the commands on this page and ask them to run them.

More on this subject

Malware removal, done for you

Malware removal is $99. Full clean-up, hardening, blacklist removal request and a written report. 30-day re-clean guarantee. It starts with a free diagnosis.