Review enough AI-written WordPress code and a pattern emerges: it looks idiomatic while the safety measures are missing. The hook names are right, the file structure is plausible, and yet four specific things are frequently absent. Get into the habit of checking those four first and review gets a lot shorter.
This is not an argument against the tool. Code written by a person in a hurry has the same gaps. What differs is volume — the model produces a great deal, quickly, so the same rate yields more absolute occurrences. That is precisely why the review should be a procedure rather than a mood.
Gap 1: unescaped output
The most common by far. The escaping function is missing where a variable reaches the page, and the choice of function is often overlooked too: esc_html() for text, esc_attr() for attribute values, esc_url() for links, wp_kses_post() where markup is genuinely wanted.
// Unsafe — user data goes straight out
echo '<h2 title="' . $label . '">' . $title . '</h2>';
// Safe — the right function for each position
echo '<h2 title="' . esc_attr( $label ) . '">' . esc_html( $title ) . '</h2>';
The review technique is blunt: find every echo and short echo tag in the changed files and check each line for escaping. Where it is missing, trace where the value came from — if the origin is the request, the database or an external API, it needs escaping.
Gap 2: SQL assembled by concatenation
Second is direct $wpdb access. Queries with variables interpolated straight into the string turn up regularly, and that is the textbook shape of SQL injection.
// Unsafe — the value is concatenated into the query
$rows = $wpdb->get_results( "SELECT * FROM {$wpdb->posts} WHERE post_author = $author" );
// Safe — values and placeholders stay separate
$rows = $wpdb->get_results(
$wpdb->prepare( "SELECT * FROM {$wpdb->posts} WHERE post_author = %d", $author )
);
One further caution: calling prepare() is not what makes it safe — the value arriving as a placeholder is. Code that interpolates first and passes the finished string to prepare() is safe in name only. Table and column names cannot be placeholders at all, so those positions are selected from an allow-list.
Part of the review is also asking whether WP_Query or get_posts() would have done the job. Direct queries are for when they are actually needed.
Gap 3: capability checks and nonces are both required
Anything that saves, deletes or changes state needs both checks. The recurring mistake is code that has one of them.
A nonce answers “did this request start from our own screen?”, blocking another site from driving a logged-in user’s browser. A capability check answers “is this user entitled to do it?”. Neither substitutes for the other: with only a nonce, any logged-in user can perform the action through your screen; with only a capability check, the path where an administrator is tricked into sending the request stays open.
// The first two lines of a save handler
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'Not permitted.', 'acme' ), 403 );
}
check_admin_referer( 'acme_save_settings' );
For a REST route the same job belongs to permission_callback. Generated code fairly often fills that argument with __return_true, which does not authorise the request — it switches authorisation off.
Gap 4: file access without path validation
Last is code that reads or removes files. The moment a request value becomes part of a path, you need something that stops input climbing out of the intended directory.
// Unsafe — the request value becomes the path
readfile( $base . '/' . $_GET['f'] );
// Safe — take only a filename, resolve it, reject anything outside the base
$name = wp_basename( sanitize_file_name( wp_unslash( $_GET['f'] ?? '' ) ) );
$path = realpath( $base . '/' . $name );
if ( ! $path || ! str_starts_with( $path, realpath( $base ) ) ) {
wp_die( esc_html__( 'Bad request.', 'acme' ), 400 );
}
The essential move is to resolve first, then compare. Attempts to strip dangerous sequences from the string are routinely bypassed, whereas fixing the real path with realpath() and requiring it to start with the base directory makes most of those bypasses irrelevant. If uploads are involved, add an extension allow-list and wp_check_filetype_and_ext().
Move the review into tooling
Nobody needs to inspect these four by eye every time. A good deal is caught by static analysis — the WordPress coding standards ruleset flags missing escaping, missing nonce verification and unprepared queries, and wired into the editor and CI it fires before review begins.
What tooling cannot supply is context: whether this endpoint really warrants manage_options, whether that value comes from a source you trust. Those are human calls. So put the automated checks first and save the attention for the judgement.
Setting the standards up as enforced tooling is covered in the development workflow archive, and further security writing lives in the Security archive. Diagnosing and closing vulnerabilities on a running site is part of our optimization program.