Malware in the WordPress Database: Where It Hides and How to Clean It
By Glenn Lyvers · Updated · 6 min read
If you have replaced every file on your site with fresh copies and the spam links, popups, or redirects are still there, the malware is not in your files — it is in your database. This is the cleanup gap I see most often in do-it-yourself recoveries: the owner reinstalls WordPress, the theme, and every plugin, watches the scanner come back green, and the site is still serving spam an hour later. File scanners read files. They do not read the millions of rows in your database, and attackers know it.
Database malware is common precisely because it survives the standard cleanup. Here is where it hides, how to find it without breaking your site, and why finding it usually means there is one more thing left to remove.
Why your scanner says clean while the site is not
Almost every malware scanner in the WordPress world works by examining files — comparing them against known-good copies, looking for suspicious patterns, flagging recent changes. That is genuinely useful, and it is also only half the picture. WordPress builds every page by pulling content out of MySQL: post content, widget contents, site options, user records. Code injected into any of those rows executes or renders on every page load without a single modified file anywhere on the server. A green file scan tells you the files are clean. It says nothing about the database.
The four places database malware lives
After years of these cleanups, nearly everything I find is in one of four places.
First, wp_options. This table holds your site's settings, and it is the single favorite hiding spot. Malicious JavaScript stuffed into a widget's stored content so it prints into your sidebar or footer on every page. Rogue rows with innocent-sounding option names holding base64-encoded payloads that a companion snippet decodes and runs. Poisoned cron entries in the cron option that re-run the attacker's code on a schedule — one of the classic engines behind reinfection. The siteurl and home rows silently rewritten to route your visitors somewhere else.
Second, wp_posts. Attackers append <script> tags or invisible spam-link blocks directly into the stored content of your pages and posts. Sometimes it is every post on the site, added in one automated pass; sometimes it is only your five most-trafficked pages, which is harder to notice. This is the mechanic behind a lot of the Japanese keyword hack and pharma hack damage — thousands of spam entries living as ordinary post rows.
Third, wp_users and wp_usermeta. An administrator account you did not create is database malware in the most literal sense — it is a persistent foothold stored in a table. I cover hunting these down in the hidden admin users guide, including the meta-table tricks that hide an admin from the dashboard's user list.
Fourth, plugin- and theme-owned rows. Anything that stores rich content in the database — page builders, popup plugins, snippet managers, custom-code plugins — can have its stored content poisoned. A code-snippets plugin is a particular prize for an attacker: it exists specifically to execute whatever PHP is stored in its table, so one injected row is a fully functional backdoor that never touches the filesystem.
How to search the database without wrecking it
The searching itself is straightforward. Through phpMyAdmin or the MySQL command line, I query the likely tables for the strings that almost never appear in legitimate content: <script tags inside wp_options values, eval(, base64_decode, display:none wrapped around link blocks in post content, and URLs pointing at domains you do not recognize. A search for recently modified posts you did not edit is also revealing — sort wp_posts by post_modified and look at what changed while you were not looking.
The part that catches people is editing what you find. Much of what WordPress stores in wp_options is serialized PHP data — structured strings with embedded length counts, like s:25: followed by exactly twenty-five characters. If you hand-edit a serialized value and change its length, the length prefix no longer matches, WordPress silently discards the entire value, and suddenly your theme settings or widgets are gone. This is why the standard advice is never to run a blind find-and-replace in raw SQL. Use tools that understand serialization — wp-cli's search-replace command handles it correctly — or delete a whole malicious row outright rather than surgically trimming a string inside it. Deleting an entire rogue option row is safe; shortening a legitimate serialized one by hand is how cleanups turn into rebuilds.
And before you touch anything: take a database export. Every deletion should be one restore away from undone. If you are unsure whether restoring an old backup outright is the better path, I wrote about exactly that decision — the short version is that a restore rolls back your content too, and only helps if the backup predates the compromise.
Cleaning it out, step by step
My order of operations on the database phase of a cleanup: export the database first, as evidence and as a safety net. Sweep wp_options for rogue rows, poisoned widgets, cron entries referencing code that should not exist, and verify siteurl and home. Clean injected script and spam blocks out of wp_posts — wp-cli makes short work of a pattern repeated across thousands of posts. Audit every user with elevated privileges. Then check the tables owned by any plugin that stores code or rich content.
When the database is clean, rotate the database password, because you should assume it was read out of wp-config.php during the compromise — a stolen database password lets the attacker skip your website entirely next time and write to MySQL directly. The wp-config guide walks through the full rotation list, salts included.
The question that actually matters: how did it get there
Database rows do not inject themselves. Something wrote them — a vulnerable plugin, a stolen password, or PHP already running on your server. If you clean every poisoned row and stop there, whatever wrote them is still in place, and it will write them again; that is the reinfection loop in its purest form. I have watched owners clean the same spam out of wp_options four times without asking what kept putting it back.
So a database cleanup is never the whole job. It pairs with a backdoor hunt through the filesystem, a look at how the attacker got in to begin with (my guide on how sites actually get hacked covers the usual doors), and the credential rotation above. Files, database, access — all three, or the cleanup does not hold.
If you would rather hand the whole thing over — the file sweep, the row-by-row database audit, the backdoor hunt, and the rotation list — that is precisely what my Bulletproof Cleaning covers, and the database pass is where I earn the name. Not sure yet whether the database is your problem? Run the free scan, or start with identifying what kind of hack you have and work forward from there.
Common questions
Can malware really hide in the WordPress database itself?
Yes, and it is one of the most common reasons cleanups fail. The usual spots are wp_options (poisoned widgets, rogue options holding encoded payloads, malicious cron entries), wp_posts (script tags and hidden spam links injected into your content), administrator accounts you did not create, and rows owned by plugins that store code or rich content. File scanners never read those rows, so database malware survives a file-only cleanup.
Why does my site still show spam after I replaced every file?
Because the spam is being rendered out of the database. WordPress builds every page from MySQL — post content, widgets, options — so injected rows keep producing spam no matter how many times the files are replaced. The fix is a row-level sweep of wp_options and wp_posts, then finding whatever wrote those rows in the first place.
Is it safe to delete rows from wp_options?
Deleting an entire malicious row is safe, provided you export the database first so any mistake is one restore away from undone. What is not safe is hand-editing serialized values — WordPress stores many options as serialized PHP with embedded length counts, and changing a string's length by hand makes WordPress silently discard the whole value. Use wp-cli's search-replace for patterns, and delete whole rogue rows rather than trimming inside them.
Will restoring a database backup remove the malware?
Only if the backup predates the compromise — and you lose all content and orders created since. Even then, restoring does nothing about how the attacker got in or the backdoors they left in your files, so an unpatched site gets reinjected. A restore can be part of the answer, but it is never the whole answer.
How did malware get into my database in the first place?
Database rows do not inject themselves — something wrote them: a vulnerable plugin or theme, a stolen password, or PHP already running on your server. That is why a database cleanup always pairs with a filesystem backdoor hunt and credential rotation. Clean the rows without closing the door and the rows come back.