Why AI plugin code fails review
Large language models can draft WordPress plugin PHP in seconds. The output often passes a smoke test in wp-admin and still fails a security review because the model optimises for syntax, not for WordPress execution context. In rescue engagements we see the same five gaps repeat: missing capability checks, absent nonce verification, unsanitised $_POST, concatenated SQL, and PHP files without an ABSPATH guard. This guide documents those patterns with anonymised snippets from real audits. The examples are deliberately broken teaching samples, not runnable exploits.
If your site was built or heavily modified by an AI assistant, these checks sit inside the broader AI-built website rescue workflow. When the custom code surface is large or you need a formal pass on the whole stack, pair this review with a WordPress security audit.
1. Missing capability checks (and misusing is_admin())
The most common access-control mistake in generated code is calling is_admin() and assuming only administrators can reach the handler. is_admin() only checks whether the current request targets the administration area. Every call to /wp-admin/admin-ajax.php satisfies that condition, including requests from subscribers and unauthenticated visitors.
Bad example from an anonymised settings handler
add_action( 'admin_init', 'acme_save_plugin_settings' );
function acme_save_plugin_settings() {
if ( is_admin() && isset( $_POST['acme_option'] ) ) {
update_option( 'acme_option', $_POST['acme_option'] );
}
}
A subscriber who can load any front-end page can POST to admin-ajax.php or trigger admin_init flows that were never meant for their role. The fix is current_user_can( 'manage_options' ) (or a narrower capability) before any write.
What we look for in review
- Handlers registered on
admin_init,wp_ajax_*, or REST routes withoutcurrent_user_can(). - Role name checks (
if ( $user->roles[0] === 'administrator' )) instead of capabilities. is_admin()used as the only gate on mutating operations.
2. Missing wp_nonce verification (CSRF)
Cross-site request forgery tricks a logged-in administrator’s browser into submitting a request the user did not intend. WordPress mitigates this with nonces. AI-generated AJAX handlers often check capabilities but skip wp_verify_nonce() or check_ajax_referer(), because the model generated the handler and the form in separate passes.
Bad example from an anonymised delete handler
add_action( 'wp_ajax_acme_delete_entry', 'acme_delete_entry_handler' );
function acme_delete_entry_handler() {
if ( ! current_user_can( 'delete_posts' ) ) {
wp_send_json_error( 'Unauthorized.' );
}
$entry_id = intval( $_POST['entry_id'] );
wp_delete_post( $entry_id, true );
wp_send_json_success( 'Deleted.' );
}
An administrator’s session is enough for an attacker to forge a POST if no nonce is required. Capability checks alone do not stop CSRF.
What we look for in review
- Any
$_POST/$_GETmutation withoutwp_verify_nonce()orcheck_ajax_referer(). - REST routes with
permission_callbackset to__return_trueor omitted. - Admin forms rendered without
wp_nonce_field().
3. Unsanitised $_POST written to the database or echoed to the page
AI output frequently moves $_POST values into update_option(), update_post_meta(), or straight into echo without sanitize_* or wp_unslash(). That opens stored XSS and option-poisoning paths even when the handler is admin-only.
Bad example from an anonymised feedback saver
add_action( 'wp_ajax_acme_save_feedback', 'acme_save_feedback' );
function acme_save_feedback() {
$comment = $_POST['user_comment'];
$list = get_option( 'acme_feedback_list', array() );
$list[] = array( 'user_comment' => $comment );
update_option( 'acme_feedback_list', $list );
wp_send_json_success();
}
No nonce, no capability check, no sanitisation. A later template that echoes $feedback['user_comment'] without esc_html() completes the stored XSS chain.
What we look for in review
$_POST,$_GET, or$_REQUESTreaching persistence withoutsanitize_text_field(),absint(), or context-appropriate sanitizers.- Output in templates without
esc_html(),esc_attr(), oresc_url()as appropriate. - Missing
wp_unslash()before sanitisation on form data.
4. SQL concatenation instead of $wpdb->prepare()
When generated code touches custom tables, models often build SQL with string concatenation. The pattern looks correct to a non-PHP reviewer because the query “works” in testing.
Bad example from an anonymised lookup function
function acme_find_records_by_ref( $ref_code ) {
global $wpdb;
$sql = "SELECT * FROM {$wpdb->prefix}acme_records WHERE ref_code = '" . $ref_code . "'";
return $wpdb->get_results( $sql );
}
If $ref_code ever comes from request input without preparation, the query structure is attacker-controlled.
What we look for in review
- Variables interpolated inside double-quoted SQL strings.
$wpdb->query()/get_results()without a preceding$wpdb->prepare().- Correct
prepare()usage but%literals not escaped when needed.
5. Direct file access without an ABSPATH guard
Every PHP file in a plugin or theme should refuse execution when WordPress has not bootstrapped. Generated snippets often omit the guard, especially in “helper” files the model split out for readability.
Bad example from an anonymised include file
<?php
// helpers.php - no bootstrap check
function acme_get_export_rows() {
global $wpdb;
return $wpdb->get_results( "SELECT * FROM {$wpdb->prefix}acme_records" );
}
Requesting /wp-content/plugins/acme-exporter/helpers.php directly may run the file outside WordPress, bypassing authentication entirely depending on server configuration.
Correct pattern
<?php
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
What we look for in review
- Any
.phpfile in the plugin tree missing the guard (includinguninstall.phpand vendor-adjacent copies). define( 'WP_USE_THEMES', false ); require 'wp-load.php';patterns in generated “API” files without authentication.
6. Hallucinated APIs and unverified dependencies
Generative models invent plausible function names: wp_verify_admin(), sanitize_all_input(), get_current_user_role(). They follow WordPress naming closely enough to survive a quick skim and fail on first execution. A separate risk is slopsquatting: a composer.json that references a package one character off a real library.
Cross-check every unfamiliar symbol against developer.wordpress.org. Verify Composer packages on Packagist before composer install. In rescue work this pattern often pushes a site from “fix one handler” to “rewrite the custom plugin” when hallucinations are scattered across files.
Audit workflow we use before production
Treat AI-generated plugin code as untrusted input. The sequence below is what we run in AI-built website rescue engagements before any custom PHP ships to production.
Step 1: Static analysis with PHPCS
Run WordPress Coding Standards against the generated tree. PHPCS flags many missing escapes, nonces, and sanitisation gaps automatically.
vendor/bin/phpcs --standard=WordPress-Extra path/to/generated-plugin/
Step 2: Manual checklist on every mutating handler
For each AJAX action, REST route, form handler, and upload path:
ABSPATHguard on every PHP file.- Nonce verified before reads of
$_POST/$_GETthat change state. current_user_can()with the narrowest capability that fits.- Input sanitised with
wp_unslash()+ appropriatesanitize_*. - Database reads and writes through
$wpdb->prepare()when SQL is dynamic. - Output escaped at the point of render.
Step 3: Low-privilege probing
Log in as a subscriber (or use no cookie) and replay the plugin’s AJAX and REST endpoints with curl or Postman. If state changes or private fields return, the permission model is incomplete. A full WordPress security audit extends this with CVE cross-checks on third-party plugins and server exposure review.
graph TD
A["Receive AI-written plugin code"] --> B{"Mutates state or reads private data?"}
B -- Yes --> C["ABSPATH guard on every PHP file"]
C --> D["Nonce + capability on handler"]
D --> E{"Touches database?"}
B -- No --> F{"Outputs to HTML?"}
E -- Yes --> G["$wpdb->prepare() for dynamic SQL"]
E -- No --> F
G --> F
F -- Yes --> H["esc_html / esc_attr / esc_url at render"]
F -- No --> I["Low-privilege curl tests"]
H --> I
When to fix the plugin vs rescue the whole site
Localised gaps (one AJAX handler, one admin form) are worth patching after review. When the same five patterns appear across multiple generated files, when hallucinated functions are wired into production paths, or when AI-chosen plugins carry live CVEs on top of bad custom code, stop patching in isolation. The AI-built website rescue audit produces an explicit keep / rewrite / remove split so you do not pay for a full rebuild when a rescue is enough, and you do not patch forever when the foundation is unsound. When the stack also carries thirty-plus plugins and broken Core Web Vitals, pair the code audit with Core Web Vitals rescue for AI-built sites.
Pricing for rescue and audit work is individual and scoped after that inventory. Contact us with the plugin or site URL and how the code was generated.
Summary
AI accelerates drafting WordPress plugin PHP. It does not replace a security review. The failures that recur in our audits are predictable: missing capabilities, missing nonces, unsanitised input, concatenated SQL, and unguarded PHP files. Run PHPCS, walk the checklist on every handler, and probe with a low-privilege account before you trust vibe-coded plugins on a production store or membership site.







