The reality of SQL injection in WordPress plugins
SQL injection vulnerabilities remain one of the most exploited attack vectors in WordPress installations, often introduced through plugins that handle database queries without proper sanitization. We've debugged production sites where a seemingly innocuous search feature became the entry point for attackers to extract customer data, modify posts, or plant malware. Preventing SQL injection in WordPress: a practical guide walks through the layers of defense we use to keep our applications secure.
Always use $wpdb->prepare for dynamic queries
The WordPress database API provides $wpdb->prepare() as the first line of defense against SQL injection. This function uses parameterized queries, which separate the SQL structure from user data. When you use prepare, WordPress handles escaping and quoting for you—meaning you don't have to guess whether a variable needs addslashes() or esc_sql().
Here's how we structure a typical query with user input:
global $wpdb;
$user_id = $_GET['user_id'];
$query = $wpdb->prepare(
"SELECT * FROM {$wpdb->users} WHERE ID = %d",
$user_id
);
$results = $wpdb->get_results( $query );
The %d placeholder tells WordPress to treat the value as an integer. This is non-negotiable for numeric IDs. If we were working with strings, we'd use %s:
$email = $_POST['user_email'];
$query = $wpdb->prepare(
"SELECT * FROM {$wpdb->users} WHERE user_email = %s",
$email
);
$user = $wpdb->get_row( $query );
The beauty of prepare is that it handles edge cases automatically. If someone submits an email like test@example.com' OR '1'='1, the prepare function quotes and escapes it so it becomes a literal string, not a SQL fragment.
We've seen plugins that concatenate queries directly into prepare calls, which defeats the purpose entirely. Always pass variables as separate arguments to prepare, never inside the SQL string itself:
// Wrong
$query = $wpdb->prepare( "SELECT * FROM {$wpdb->posts} WHERE post_author = " . $user_id );
// Correct
$query = $wpdb->prepare(
"SELECT * FROM {$wpdb->posts} WHERE post_author = %d",
$user_id
);
Use WP_Query when possible instead of raw SQL
WordPress provides higher-level abstractions like WP_Query, WP_User_Query, and WP_Term_Query that handle SQL generation and injection prevention for you. When you're querying posts, users, or terms—which covers most plugin use cases—these classes are safer and more maintainable than hand-written SQL.
Consider a scenario where you need to find posts by a specific author published in the last 30 days:
// Using WP_Query (preferred)
$args = array(
'post_author' => absint( $_GET['author_id'] ),
'posts_per_page' => 10,
'date_query' => array(
array(
'after' => '30 days ago',
'inclusive' => true,
),
),
);
$query = new WP_Query( $args );
$posts = $query->get_posts();
This approach is cleaner and injection-proof. The WP_Query class validates parameters internally and constructs the SQL safely. Compare that to raw SQL:
// Using raw SQL with prepare (necessary for complex queries)
global $wpdb;
$author_id = absint( $_GET['author_id'] );
$thirty_days_ago = date( 'Y-m-d H:i:s', strtotime( '-30 days' ) );
$query = $wpdb->prepare(
"SELECT ID, post_title, post_date FROM {$wpdb->posts}
WHERE post_author = %d
AND post_date > %s
AND post_status = %s
LIMIT 10",
$author_id,
$thirty_days_ago,
'publish'
);
$results = $wpdb->get_results( $query );
Both are safe, but WP_Query requires less defensive coding. We use raw SQL only when WordPress APIs don't support the query structure we need—complex joins, aggregations, or custom post type relationships that don't fit standard parameters.
Validate and cast types before queries
Type validation is your second barrier. Before any data enters a query, confirm it matches your expectations. WordPress provides helper functions like absint(), intval(), and floatval() for casting, plus sanitize_text_field() and sanitize_email() for strings.
In a real project, we recently had a filtering feature that accepted numeric price ranges. The initial code looked like this:
// Request: ?min_price=100&max_price=500
$min_price = $_GET['min_price'];
$max_price = $_GET['max_price'];
// Even with prepare, validate first
$query = $wpdb->prepare(
"SELECT * FROM products WHERE price BETWEEN %d AND %d",
$min_price,
$max_price
);
Without type casting, PHP might do unexpected conversions. If someone submits min_price=abc, PHP converts it to 0 silently. That's a logic bug, not an injection, but validation catches it early:
$min_price = absint( $_GET['min_price'] ?? 0 );
$max_price = absint( $_GET['max_price'] ?? 1000000 );
// Validate ranges make sense
if ( $min_price < 0 || $max_price < $min_price ) {
return new WP_Error( 'invalid_range', 'Price range is invalid' );
}
$query = $wpdb->prepare(
"SELECT * FROM products WHERE price BETWEEN %d AND %d",
$min_price,
$max_price
);
For strings, use sanitize_text_field() before prepare. This removes HTML tags and invalid characters, reducing the attack surface:
$search_term = sanitize_text_field( $_GET['s'] ?? '' );
if ( empty( $search_term ) ) {
return new WP_Error( 'empty_search', 'Search term cannot be empty' );
}
$query = $wpdb->prepare(
"SELECT * FROM posts WHERE post_title LIKE %s",
'%' . $wpdb->esc_like( $search_term ) . '%'
);
Notice the $wpdb->esc_like() call. LIKE queries have their own special characters (% and _), so we escape those separately even after prepare.
Never trust request data without verification
This is the hardest habit to break, but it's essential: assume all data from $_GET, $_POST, $_REQUEST, and even $_COOKIE is malicious until proven otherwise. We see plugins that skip validation entirely because the developer thought "only our admin would use this" or "this is an internal API."
Request data can be modified by anyone with network access, including the user themselves through browser developer tools. Your admin endpoints are just as vulnerable if you don't validate, because attackers can craft requests that look like they're coming from an authenticated user.
A typical pattern we enforce:
if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( $_POST['nonce'], 'action_name' ) ) {
wp_die( 'Security check failed' );
}
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( 'Unauthorized' );
}
$post_id = absint( $_POST['post_id'] ?? 0 );
if ( $post_id === 0 ) {
return new WP_Error( 'invalid_post', 'Post ID is required' );
}
// Only now query the database
$post = get_post( $post_id );
if ( ! $post ) {
return new WP_Error( 'post_not_found', 'Post not found' );
}
Each layer adds a check: nonce verification, capability checking, type validation, and existence verification. This defense-in-depth approach means that even if one layer fails, others catch the problem.
Common mistakes we see in production
Mixing prepare with concatenation: Developers sometimes think prepare is optional if they "sanitize" with regex or string functions. It's not. preg_replace() doesn't prevent injection; prepare does.
Using prepare with table or column names: Prepare can't escape table or column names safely. If you need dynamic table names, use whitelisting:
$allowed_columns = array( 'post_title', 'post_date', 'post_author' );
$order_by = $_GET['order_by'] ?? 'post_date';
if ( ! in_array( $order_by, $allowed_columns, true ) ) {
return new WP_Error( 'invalid_column', 'Invalid column' );
}
$query = $wpdb->prepare(
"SELECT * FROM {$wpdb->posts} ORDER BY " . $order_by
);
Forgetting about meta queries: Custom post meta can also be vulnerable. Always use get_post_meta() or the WP_Query meta_query parameter instead of constructing your own postmeta SQL.
Assuming nonces replace prepare: Nonces verify intent from your own site, but they don't prevent injection. Use both: nonces + prepare.
Key takeaways
- Every dynamic SQL query must use
$wpdb->prepare()with placeholders (%d,%s,%i). No exceptions. - Prefer
WP_Query,WP_User_Query, and similar abstractions when they cover your use case—they handle injection prevention internally. - Cast and validate all request data with functions like
absint(),sanitize_text_field(), andintval()before it reaches a query. - Verify capabilities, nonces, and data existence before querying. Defense in depth catches mistakes.
- Table and column names can't be safely parameterized; use whitelisting for dynamic names.
- Test your plugin with tools like SQLmap to verify injection isn't possible. Automated testing catches regressions.






Comments
Loading comments…