Auditing AI-generated WordPress plugin code

Auditing AI-generated WordPress plugin code

Updated: October 27, 2026
16 min read
Guide
500+ WP projects
Security auditor

#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 without current_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 / $_GET mutation without wp_verify_nonce() or check_ajax_referer().
  • REST routes with permission_callback set to __return_true or 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 $_REQUEST reaching persistence without sanitize_text_field(), absint(), or context-appropriate sanitizers.
  • Output in templates without esc_html(), esc_attr(), or esc_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 .php file in the plugin tree missing the guard (including uninstall.php and 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:

  1. ABSPATH guard on every PHP file.
  2. Nonce verified before reads of $_POST / $_GET that change state.
  3. current_user_can() with the narrowest capability that fits.
  4. Input sanitised with wp_unslash() + appropriate sanitize_*.
  5. Database reads and writes through $wpdb->prepare() when SQL is dynamic.
  6. 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

If 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.php

From 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:

CheckWhat it reportsPattern in this guide
direct_file_accessPHP files without the ABSPATH guarddirect file access
late_escapingoutput sent to the browser without escapingXSS
direct_dbunescaped values in direct database queriesSQL injection
direct_db_queriesdirect database queries in generalSQL injection
safe_redirectwp_redirect() where wp_safe_redirect() belongsopen 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.

Next step

Turn the article into an actual implementation

This block strengthens internal linking and gives readers the most relevant next move instead of leaving them at a dead end.

Explore other WordPress services and knowledge base

Strengthen your business with professional technical support in key areas of the WordPress ecosystem.

Frequently asked questions

Practical answers to apply the topic in real execution.

Why is AI-generated WordPress plugin code often insecure#
Large language models predict plausible PHP from public repositories, much of which predates modern WordPress security practice. The output is syntactically valid but often skips nonce verification, capability checks, sanitisation, and prepared statements because those require coordinated front-end and back-end context the model does not hold across generations.
What is the difference between is_admin() and current_user_can()#
is_admin() returns true for any request routed through wp-admin, including admin-ajax.php calls from subscribers or anonymous visitors. current_user_can() checks whether the logged-in user has a specific capability such as manage_options. Access control must use capabilities, not is_admin().
How does AI introduce SQL injection in WordPress plugins#
The common pattern is concatenating $_POST or $_GET values directly into a SQL string passed to $wpdb->query() or $wpdb->get_results(). WordPress expects dynamic values to go through $wpdb->prepare() with format specifiers (%s, %d, %f).
Why does every plugin PHP file need an ABSPATH guard#
Without if ( ! defined( 'ABSPATH' ) ) { exit; } at the top of each file, an attacker can request the PHP file directly over HTTP. The script may execute outside WordPress, skipping authentication, nonces, and capability checks entirely.
When should I rescue a vibe-coded site instead of rewriting the plugin#
If the failure patterns are localised (one handler missing a nonce, one query without prepare) and the rest of the stack is sound, a targeted fix after audit is enough. If hallucinated APIs, missing guards, and sprawl appear across multiple custom files, scope a broader rescue or rebuild. Our AI-built website rescue page walks through that triage.

Need an FAQ tailored to your industry and market? I can build one aligned with your business goals.

Let’s discuss

Related Articles

PHP2Shell: is my WordPress site at risk and what to do

PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35, released on 21 October 2026, fix two critical vulnerabilities in PHP itself (CVE-2026-109618, CVE-2026-109619). How to check the PHP version in WordPress, what to do when your host has not updated the server yet, and which passwords to change after the update.