Technote

Working with AI Advanced

Security review for AI-written code: four recurring gaps

Generated code usually looks idiomatic. The trouble is that it can look idiomatic while omitting precisely the escaping, the prepared statement, the capability check or the path validation — and a fixed review order makes finding that quick.

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

Fixing the four positions you look at first is most of the saving

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.

More on this topic

All technotes

Working with AI Advanced

A project context file, so the same mistake is not repeated

Every session starts blank, but a project's decisions and incident history must not. Collect conventions, reasoning, traps already hit and regression checks in one document, and the same…

Developers 9 min read

Working with AI Practical

What AI code review catches, and what it cannot

Missing escaping and missing capability checks are caught reliably. What has to be executed to be known — runtime behaviour, hook ordering across plugins, whether the change matches…

Developers 7 min read

Working with AI Practical

Writing WordPress code with AI: context decides the result

The same question gives wildly different answers, and the variable is usually not the model but what you handed over. Supply your conventions, your version constraints and the…

Developers 10 min read

₩270,000 · Join the program