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. is_admin() vs current_user_can() in WordPress plugins
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.
Settings handler gated only by is_admin()
add_action( 'admin_init', 'fjord_save_plugin_settings' );
function fjord_save_plugin_settings() {
if ( is_admin() && isset( $_POST['fjord_option'] ) ) {
update_option( 'fjord_option', $_POST['fjord_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.
Capability check review checklist
- 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. How to verify a nonce in WordPress AJAX
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.
AJAX delete handler without a nonce
add_action( 'wp_ajax_fjord_delete_entry', 'fjord_delete_entry_handler' );
function fjord_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.
Nonce and CSRF review checklist
- 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().
What a WordPress nonce does not protect against
A nonce proves that a request came from a form or link WordPress rendered for this user, and nothing more. The nonces chapter of the WordPress security APIs says nonces must never be relied on for authentication, authorization or access control, which is why the capability check stays mandatory next to it. Three properties matter in review. A nonce is generated per user and per session, so a token copied from one account fails for another. The default lifetime is 24 hours, split into two ticks, and WordPress accepts the current and the previous tick, so a given token works for between 12 and 24 hours. A nonce is also not single use: the same value can be sent again inside that window, so it does not stop replay. A generated handler that treats a valid nonce as proof of identity has the CSRF check and still no access control.
3. How to sanitise $_POST before update_option and echo
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.
Feedback handler saving raw $_POST
add_action( 'wp_ajax_fjord_save_feedback', 'fjord_save_feedback' );
function fjord_save_feedback() {
$comment = $_POST['user_comment'];
$list = get_option( 'fjord_feedback_list', array() );
$list[] = array( 'user_comment' => $comment );
update_option( 'fjord_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.
Sanitisation and escaping review checklist
$_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.
Lookup function with concatenated SQL
function fjord_find_records_by_ref( $ref_code ) {
global $wpdb;
$sql = "SELECT * FROM {$wpdb->prefix}fjord_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.
$wpdb->prepare() review checklist
- 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. How to block direct access to plugin PHP files
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.
Helper file without an ABSPATH guard
<?php
// helpers.php - no bootstrap check
function fjord_get_export_rows() {
global $wpdb;
return $wpdb->get_results( "SELECT * FROM {$wpdb->prefix}fjord_records" );
}Requesting /wp-content/plugins/fjord-exporter/helpers.php directly may run the file outside WordPress, bypassing authentication entirely depending on server configuration.
ABSPATH guard for plugin PHP files
<?php
if ( ! defined( 'ABSPATH' ) ) {
exit;
}Direct file access review checklist
- 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. Fake WordPress functions and slopsquatted Composer packages
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.
7. Secure file upload in WordPress with wp_handle_upload
Letting users upload files (contact forms with attachments, avatar uploads) is one of the riskiest operations on a site. A mistake here lets an attacker upload a .php file, which leads to remote code execution and a full server compromise.
Generated upload handlers often rely on PHP’s native move_uploaded_file() combined with a weak extension check (plain string matching) that is easy to bypass, for example with a file named malicious.php.jpg or an alternative extension such as .phtml.
Upload with move_uploaded_file() and an extension check
function fjord_handle_avatar_upload() {
if ( isset( $_FILES['avatar'] ) ) {
$file_name = $_FILES['avatar']['name'];
$ext = pathinfo( $file_name, PATHINFO_EXTENSION );
// The model assumes this extension check is enough
if ( in_array( $ext, ['jpg', 'jpeg', 'png'] ) ) {
$target = wp_upload_dir()['path'] . '/' . $file_name;
move_uploaded_file( $_FILES['avatar']['tmp_name'], $target );
}
}
}File upload with wp_handle_upload() and a MIME allow-list
Do not call move_uploaded_file() directly inside WordPress. Use the core function wp_handle_upload(). It runs the upload through wp_check_filetype_and_ext(), which tries to determine the real file type (with getimagesize() for images and the fileinfo extension for other files) instead of trusting the name, checks the upload status and respects the allowed MIME types you pass in.
function fjord_secure_avatar_upload() {
// 1. Validate the CSRF token
if ( ! isset( $_POST['avatar_upload_nonce'] ) || ! wp_verify_nonce( sanitize_key( wp_unslash( $_POST['avatar_upload_nonce'] ) ), 'upload_avatar_action' ) ) {
wp_die( esc_html__( 'Invalid security token.', 'fjord-plugin' ) );
}
// 2. Check the capability
if ( ! current_user_can( 'upload_files' ) ) {
wp_die( esc_html__( 'You are not allowed to upload files.', 'fjord-plugin' ) );
}
if ( isset( $_FILES['avatar'] ) ) {
if ( ! function_exists( 'wp_handle_upload' ) ) {
require_once ABSPATH . 'wp-admin/includes/file.php';
}
// 3. Allow only these MIME types
$upload_overrides = [
'test_form' => false,
'mimes' => [
'jpg|jpeg' => 'image/jpeg',
'png' => 'image/png',
],
];
// 4. Hand the upload to core
$movefile = wp_handle_upload( $_FILES['avatar'], $upload_overrides );
if ( $movefile && ! isset( $movefile['error'] ) ) {
update_user_meta( get_current_user_id(), 'user_avatar', esc_url_raw( $movefile['url'] ) );
} else {
error_log( 'Upload failed: ' . $movefile['error'] );
}
}
}8. REST API routes without a permission_callback
Generated plugins often expose data through register_rest_route() instead of admin-ajax.php. The access decision for a REST route lives in a single argument, permission_callback, and models get it wrong in two ways: they leave it out, or they set it to __return_true to make a 401 error in testing go away.
The REST API handbook covers both cases. Since WordPress 5.5 a route registered without permission_callback triggers a _doing_it_wrong notice that names the route, so a missing callback shows up in the debug log of any site running with WP_DEBUG. __return_true is the documented value for endpoints that are meant to be public. No notice fires for it, so a write route marked public by mistake passes silently.
Write route open to everyone
register_rest_route( 'fjord/v1', '/settings', array(
'methods' => 'POST',
'callback' => 'fjord_update_settings',
'permission_callback' => '__return_true',
) );Write route with a capability check and validated arguments
register_rest_route( 'fjord/v1', '/settings', array(
'methods' => WP_REST_Server::EDITABLE,
'callback' => 'fjord_update_settings',
'permission_callback' => function () {
return current_user_can( 'manage_options' );
},
'args' => array(
'label' => array(
'type' => 'string',
'required' => true,
'validate_callback' => function ( $value ) {
return is_string( $value ) && strlen( $value ) <= 100;
},
'sanitize_callback' => 'sanitize_text_field',
),
),
) );For each argument, validate_callback returns true or false and the REST server rejects invalid input with an error the client can read, while sanitize_callback cleans the value before it reaches your callback. In review, list every register_rest_route() call and read its permission_callback first: a __return_true on any route that accepts more than GET needs a written reason. For cookie-authenticated requests from the browser, the authentication chapter adds one more rule: without a wp_rest nonce in the X-WP-Nonce header the API sets the current user to 0, so a front-end script that “works” only because it skips the nonce is really calling the route as a logged-out visitor.
How to audit AI-generated WordPress plugin code
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/Which WPCS sniffs catch the security patterns
WordPress Coding Standards 3.x installs only through Composer and needs PHP 7.2 or later; the WordPressCS repository tagged version 3.4.1 in July 2026.
composer config allow-plugins.dealerdirect/phpcodesniffer-composer-installer true
composer require --dev wp-coding-standards/wpcs:"^3.0"WordPress-Extra contains WordPress-Core plus recommended practices. The security sniffs sit in two groups. WordPress.Security.NonceVerification reports reads of $_POST and $_GET with no nonce check before them, WordPress.Security.ValidatedSanitizedInput reports superglobals used without isset(), wp_unslash() and a sanitizer, WordPress.Security.EscapeOutput reports unescaped output, and WordPress.Security.SafeRedirect reports wp_redirect() where a safe redirect belongs. On the database side, WordPress.DB.PreparedSQL and WordPress.DB.PreparedSQLPlaceholders report interpolated SQL and placeholder mistakes, and WordPress.DB.DirectDatabaseQuery marks direct queries for review. To run only the security set against a generated plugin:
vendor/bin/phpcs --standard=WordPress --sniffs=WordPress.Security.NonceVerification,WordPress.Security.ValidatedSanitizedInput,WordPress.Security.EscapeOutput,WordPress.DB.PreparedSQL path/to/generated-plugin/No sniff can tell whether a handler checks the right capability, so the current_user_can() review in step 2 stays manual.
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 --> IIf an AI coding agent does part of this review, Cloudflare’s security-audit skill, published under the MIT licence (README read in October 2026), shows how to keep its findings honest. Every candidate finding goes to a fresh verifier agent that tries to disprove it, so the agent that checks a finding is never the agent that found it. A lead that cannot be proved stays marked needs_validation, with no severity, and does not appear in the report as a vulnerability. A missing second layer of defence, where the first layer already blocks the attack, counts as a hardening note. Cloudflare adds that in its own test runs a single run found roughly half of the vulnerabilities that repeated runs found in total. For a generated plugin, one AI pass is a starting point; PHPCS, the handler checklist and low-privilege probing still apply.
Update, . Two more checks belong on this list, and WooCommerce extensions need both. The first concerns rendering a post or product by ID. Any function that takes a post or product ID from a request or a shortcode attribute has to check status, visibility and password before output, or a draft, private or password-protected product can show its name, price or image through a shortcode, a quick view or a compare list. Grep the PHP for get_post(, wc_get_product( and shortcode_atts(, follow each ID to the output, and look for a status and visibility check (such as current_user_can( 'read_post', $id ) or $product->is_visible()) plus post_password_required(). The second check sits in the front end. WooCommerce fires its variation and cart events through jQuery, so a native addEventListener on names such as found_variation or updated_cart_totals never fires. Grep the JavaScript for those event names and confirm the listeners go through jQuery, for example jQuery( document.body ).on( 'updated_cart_totals', ... ).
How to run Plugin Check on an AI-generated plugin
Plugin Check is the tool WordPress.org publishes to test plugins against the directory requirements, and it is just as useful on a private plugin that will never be submitted. Version 2.1.0 was tagged in August 2026. It has two modes: static checks that read the code, partly through PHP_CodeSniffer sniffs and partly through custom rules, and runtime checks that activate the plugin and execute parts of it. The project advises against running it on a production site, so install it on staging.
wp plugin check fjord-exporter
wp plugin check fjord-exporter --categories=security --format=csv --fields=code,type,severity,message
wp plugin check fjord-exporter --require=./wp-content/plugins/plugin-check/cli.phpFrom WP-CLI only the static checks run by default; the third command loads cli.php so the runtime checks run as well. The list of checks puts five of them in the security category, and they line up with the patterns in this guide:
| Check | What it reports | Pattern in this guide |
|---|---|---|
direct_file_access | PHP files without the ABSPATH guard | direct file access |
late_escaping | output sent to the browser without escaping | XSS |
direct_db | unescaped values in direct database queries | SQL injection |
direct_db_queries | direct database queries in general | SQL injection |
safe_redirect | wp_redirect() where wp_safe_redirect() belongs | open redirect |
The PHPCS ruleset behind the plugin_review_phpcs check also loads the WPCS nonce, input sanitisation and prepared SQL sniffs, but it sits in the plugin_repo category, so --categories=security hides those results. One more check is specific to generated code: file_type flags AI development directories such as .cursor, .claude, .aider, .continue, .windsurf and .ai left inside the plugin, which happens when the plugin folder was the working directory of a coding assistant. Each result has a type (ERROR or WARNING) and a severity from 1 to 10. Plugin Check does not know which capability a handler should require, so it shortens the manual review without replacing it.
When to patch an AI plugin and when to 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.







