Technote

Security Practical

Direct $wpdb queries: the four places prepare() gets missed

The rule is short. What trips people up is that placeholders do not work everywhere — and review misses always land in the same four places.

WordPress lets you answer most queries through WP_Query and get_posts(), and escaping is already built into that path. Aggregations and joins still push you down to $wpdb, though, and from that moment the responsibility for escaping moves from core to you.

The rule is one line: values go through placeholders. What is hard is not the rule but the places where placeholders do not apply — and code review misses land in the same four spots every time.

Every value goes through a placeholder

$wpdb->prepare() takes the SQL and the values separately. Where a value belongs you write %s, %d or %f, and pass the actual value as an argument. SQL assembled by string concatenation has lost the boundary between data and syntax, and losing that boundary is the definition of SQL injection.

// ✗ Request data concatenated into the SQL. Reject this shape on sight.
$rows = $wpdb->get_results( "SELECT ID FROM {$wpdb->posts} WHERE post_author = " . $_GET['author'] );

// ✓ Placeholder in the query, value as an argument
$author_id = absint( $_GET['author'] ?? 0 );

$rows = $wpdb->get_results(
	$wpdb->prepare(
		"SELECT ID FROM {$wpdb->posts} WHERE post_author = %d AND post_status = %s",
		$author_id,
		'publish'
	)
);

Here is the first trap. Do not wrap placeholders in quotes. prepare() quotes the value for you, so writing '%s' doubles the quoting. Recent core versions strip the surrounding quotes and raise a _doing_it_wrong() notice — the page keeps working while the log quietly repeats that the code is wrong.

// ✗ Doubled quoting
$wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_name = '%s'", $slug );

// ✓ Placeholder only
$wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s", $slug );

A table name is not a value

The second spot. %s quotes whatever it receives, so using it for a table name produces FROM 'wp_postmeta' and the statement will not parse. Identifiers — table and column names — are not values, and value placeholders do not accept them.

WordPress 6.2 added %i, an identifier placeholder that wraps the name in backticks. Even so, keep a whitelist alongside it: safely quoting a name and never querying a table you did not intend to expose are two different guarantees. A whitelist is not escaping — it is your intent, written down where the next reader can see it.

// ✗ A value placeholder cannot carry a table name
$wpdb->prepare( "SELECT * FROM %s WHERE post_id = %d", $table, $id );

// ✓ Pick from a whitelist, and refuse to query at all if it is not on the list
$allowed = array(
	'meta'  => $wpdb->postmeta,
	'terms' => $wpdb->term_relationships,
);

if ( ! isset( $allowed[ $which ] ) ) {
	return array();
}

$table = $allowed[ $which ];

$rows = $wpdb->get_results(
	$wpdb->prepare( "SELECT * FROM {$table} WHERE post_id = %d", $id )
);

IN and LIKE — where you build the placeholders yourself

Third is IN. Hand an array to IN (%s) and the whole array is quoted as a single string, so the condition means something else entirely. You have to generate one placeholder per item. An empty array is worth guarding too: it produces IN (), which is a syntax error.

$ids = array_filter( array_map( 'absint', (array) $ids ) );

if ( ! $ids ) {
	return array();   // never emit IN ()
}

$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );

$found = $wpdb->get_col(
	$wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE ID IN ({$placeholders})", $ids )
);

Fourth is LIKE, which reads % and _ as wildcards. If the user’s search term contains either, the query matches a different set than you intended. Neutralise them with $wpdb->esc_like() first, then add the wildcards you meant, then pass the whole thing as %s. The order is the rule — calling esc_like() after prepare() accomplishes nothing at all.

// Order: esc_like() → add wildcards → prepare()
$like = '%' . $wpdb->esc_like( $keyword ) . '%';

$rows = $wpdb->get_results(
	$wpdb->prepare( "SELECT ID, post_title FROM {$wpdb->posts} WHERE post_title LIKE %s", $like )
);

What review should look for

All four have a recognisable shape. That means they can become review checklist items, and once written down the standard survives a change of reviewer.

Review items for hand-written queries — the top five are an automatic rejection

One last note: $wpdb->insert(), update() and delete() take a format array and escape for you, so there is almost never a reason to assemble a write statement by hand. For reads, climbing back up to WP_Query is usually the better move — drop to raw SQL and you inherit responsibility for the object cache, the hooks and the language filters that came free above it.

Input handling and permission checks are covered together in the security archive, and the procedure we follow when finding and fixing these patterns in somebody else’s code is published on our process page. If you would rather hand a running site’s vulnerability review over as one job, see the optimization program.

More on this topic

All technotes

Security Intro

Update order: core, then plugins, then theme

Updating everything at once is quick, but when it breaks you cannot tell what did it. An order with checks in between is not slower — it keeps…

Site operators 6 min read

Security Intro

Hardening starts by counting your administrators

However many doors you close in configuration, eight people holding keys means eight things to protect. A role audit comes before every other hardening step.

Site operators 7 min read

₩270,000 · Join the program