
Most SQL injection bugs in WordPress do not come from core. Core’s database layer is solid. They come from custom code, the plugin or theme query someone wrote by hand to pull a custom post type, filter by a URL parameter, or build a report. If you write PHP that touches the database directly, this guide is for you.
Why Custom Queries Are the Risk Zone
WordPress gives you $wpdb, a database access class that wraps the underlying MySQL or MariaDB connection. Most of what a typical plugin needs, like fetching posts or saving options, can go through the standard WordPress functions (get_posts(), update_option(), and so on), which already handle escaping correctly.
The risk shows up when a developer needs something those functions do not cover: a custom table, a complex join, a report grouped by date range. At that point the natural move is to write raw SQL and concatenate in values from user input, a URL parameter, a form field, a cookie. That concatenation step is where SQL injection is born.
The Anatomy of a SQL Injection in WordPress
Here is a simplified but realistic example of vulnerable code in a plugin that looks up an order by ID from the query string:
global $wpdb;
$order_id = $_GET['order_id'];
$results = $wpdb->get_results(
"SELECT * FROM {$wpdb->prefix}shop_orders WHERE id = $order_id"
);If order_id is a plain number, this works fine. But nothing stops an attacker from sending something like 0 OR 1=1 or a more elaborate payload that appends a second statement or extracts data through a subquery. Because the value is inserted directly into the SQL string, the database cannot tell the difference between “data” and “code.” That is the entire problem in one sentence: unescaped, unparameterized user input becomes part of the SQL the server executes.
How $wpdb->prepare Works
$wpdb->prepare() fixes this by separating the SQL structure from the data. You write a query template with placeholders, then pass the values as separate arguments. WordPress escapes and quotes each value according to its type before it ever touches the query string.
global $wpdb;
$order_id = absint( $_GET['order_id'] );
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}shop_orders WHERE id = %d",
$order_id
)
);The placeholders you will use most:
%dfor integers%ffor floats%sfor strings (this one gets quoted and escaped automatically)
Note the absint() call before the value even reaches prepare(). Type-casting or sanitizing input at the point of entry is good practice on top of prepare, not a replacement for it. Belt and suspenders.
For multiple values, list them in order:
$wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}shop_orders WHERE status = %s AND total > %f",
$status,
$min_total
);Common Mistakes Even With prepare
Using prepare() is necessary but developers still trip on a few patterns:
- Table and column names cannot be placeholders.
%swill quote a table name as a string literal, which breaks the query. If a table name is dynamic (rare, and worth questioning why), validate it against an allow-list of known table names, then interpolate it directly, not through prepare. - ORDER BY and LIMIT clauses often need the same allow-list treatment. You cannot parameterize a column name for sorting. Map user input to a small, fixed set of acceptable column names in code, and reject anything else.
- IN() clauses need one placeholder per value. Do not pass a comma-joined string as a single
%s. Build the placeholder list to match the array count:
$ids = array( 4, 7, 12 );
$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
$query = $wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}shop_orders WHERE id IN ($placeholders)",
$ids
);%s markers and you pass two values, you get a broken query, not a helpful error. Count carefully.Beyond prepare: Built-in Helpers
For straightforward insert, update, and delete operations, skip raw SQL entirely and use the $wpdb helper methods, which handle escaping for you:
$wpdb->insert(
$wpdb->prefix . 'shop_orders',
array(
'customer_id' => $customer_id,
'total' => $total,
'status' => $status,
),
array( '%d', '%f', '%s' )
);The format array at the end tells $wpdb how to treat each value, the same placeholder logic as prepare(). The same pattern exists for $wpdb->update() and $wpdb->delete(). Reach for these before writing raw SQL by hand.
Code Review Habits That Catch This Early
Most SQL injection bugs get caught in review, not in production. A few habits make that reliable:
- Grep for direct query calls. Search a plugin or theme’s codebase for
$wpdb->query,$wpdb->get_results,$wpdb->get_row, and$wpdb->get_var. Every hit is worth a look. If any of them build the SQL string with variable concatenation instead ofprepare(), flag it. - Trace every user input to its use. Ask where each value in a query originated:
$_GET,$_POST,$_COOKIE, a REST API parameter. If it can be influenced by a site visitor and it ends up in SQL, it needs to go throughprepare()or a type cast, ideally both. - Never trust
esc_sql()alone for full queries. It escapes a value for safe inclusion in a query, butprepare()is the safer, more explicit tool for full statements because it handles quoting and type coercion in one step. - Run a static analysis pass. Tools built for WordPress coding standards can flag unescaped variables in SQL calls automatically, catching what a quick visual scan might miss in a large codebase.
- Apply least privilege to the database user. A plugin’s database account should have only the permissions it needs. If a query does slip through, a restricted account limits the damage an attacker can do.
Defense in Depth
Good query hygiene is the real fix, but layered defenses matter too. A web application firewall, like the one Cloudflare provides at the edge, can block many injection payloads before they reach your server, buying you time if a vulnerability surfaces before a patch ships. Keep backups current and tested, keep DISALLOW_FILE_EDIT on, and treat a WAF as a safety net, not a substitute for writing safe queries in the first place.
Takeaway
SQL injection in WordPress almost always traces back to raw SQL built from unescaped user input. The fix is not complicated: use $wpdb->prepare() for every query touching a variable, use the built-in insert/update/delete helpers where you can, allow-list anything that cannot be parameterized like table or column names, and make grepping for direct query calls part of your normal review process. That habit, repeated consistently, closes the door on the most common class of database vulnerability in the WordPress ecosystem.