The Prepared Statement That Wasn't
How a WordPress query kept its injection while wearing a safety vest
Published Oct 29, 2026
Executive Summary
A plugin looks up orders by customer email using $wpdb->prepare() — but the SQL is assembled by string concatenation first, so prepare() has no placeholders left to escape and returns the string unchanged. The raw email reaches MySQL, and the 'prepared' query is classic SQL injection in disguise.
The Prompt
“Add a lookup that lists a customer's orders by email address.”
The Flaw
$wpdb->prepare() only escapes values you pass as %s/%d placeholders. When the SQL string is assembled with concatenation first, there are no placeholders left, so prepare() returns the string unchanged and the raw input reaches MySQL. An email like ' OR '1'='1' -- turns the lookup into SELECT * FROM wp_orders WHERE customer_email = '' OR '1'='1' -- ... and every row comes back.
Risk & Impact
[Security] The lookup returns every order in the database. The query looks safe because it is wrapped in prepare(), so reviewers and scanners give it a pass — until someone appends ' OR '1'='1 to the email parameter.
Cost: Full database exposure through a single query parameter.
Code Comparison
❌ Vulnerable (AI generated)
❌ Vulnerable (AI generated)
✅ Hardened (production ready)
The Fix
Pass the raw value as a %s placeholder argument — the escaping happens inside prepare(), not in the string. Never concatenate input before preparing.
Prevention
- [linter] PHPCS with WordPress.DB.PreparedSQL flags $wpdb queries that concatenate input instead of using placeholders.
- [ci] A security test that submits ' OR '1'='1 as the email must return zero rows — fail the build otherwise.
- [manual] Show the agent one correct $wpdb->prepare() call with a %s placeholder to mirror, and forbid string-built SQL outright.
Why This Happened
Root cause: the prompt omitted Rule 3 and Rule 6 — the guardrails that would have caught this. See the full guide →
