Plugin security checklist

Review a plugin the way an attacker reads it, entry point by entry point, and fix the vulnerabilities that appear most often in real plugins.

  • Intermediate
  • 8 min read
  • Updated 17 Oct 2026

What you will learn

  • Review a plugin for security
  • Fix common vulnerabilities
  • List every entry point a plugin exposes
  • Spot broken access control, including insecure direct object references

Before you start

Most plugin vulnerabilities are not clever. They are a missing capability check, a value printed without escaping, or a query built with string concatenation. The WordPress course taught each defence on its own. This lesson turns them into a review routine you can run on your own plugin before each release, or on a third-party plugin before you install it on a client site.

Step 1: list every entry point

An attacker does not read your plugin from top to bottom. They look for every way a request can reach your code. Make the same list:

Entry pointHow to find itReachable by
Admin pages and their formsadd_menu_page, add_submenu_page, load- hooksUsers with the menu capability
admin-post.php handlersadmin_post_ and admin_post_nopriv_Logged-in users; anyone for nopriv
AJAX handlerswp_ajax_ and wp_ajax_nopriv_Every logged-in user; anyone for nopriv
REST routesregister_rest_routeWhatever the permission_callback allows
Shortcodes and blocksadd_shortcode, register_block_typeAnyone who can write a post
Hooks on init, admin_init, template_redirect that read $_GET or $_POSTSearch for superglobalsAnyone, on every request
Cron and Action Scheduler callbacksScheduled hooksIndirectly, through data they process
File uploads and imports$_FILES, wp_handle_uploadWhoever can reach the form

A quick way to build the list is to search the codebase:

Shell
grep -rnE "wp_ajax_|admin_post_|register_rest_route|add_shortcode|\\\$_(GET|POST|REQUEST|FILES|COOKIE)" --include=*.php myplugin/

Then work through each entry point with the checks below.

Step 2: who may do this?

Check a capability for every action that reads private data or changes anything. The most common real-world plugin vulnerability is a handler that checks nothing, or checks the wrong thing.

Wrong checks that look right:

  • is_admin(): true for any user on any admin screen, and for every admin-ajax.php request.
  • is_user_logged_in(): true for subscribers and customers.
  • A nonce on its own: proves intent, not permission.
  • Hiding the button: the handler is still reachable directly.

Check the specific capability, with the object ID when the action targets one object: current_user_can( 'edit_post', $event_id ), not current_user_can( 'edit_posts' ).

Insecure direct object references

The subtle version is a handler that checks the user may act in general, but not on this object:

PHP
<?php
// Vulnerable: any logged-in user can cancel any booking by changing the ID.
add_action( 'wp_ajax_myplugin_cancel_booking', 'myplugin_ajax_cancel_booking' );

function myplugin_ajax_cancel_booking(): void {
    check_ajax_referer( 'myplugin_cancel_booking', 'nonce' );

    $booking_id = absint( $_POST['booking_id'] ?? 0 );
    myplugin_set_booking_status( $booking_id, 'cancelled' );

    wp_send_json_success();
}

The fix loads the booking and checks ownership, with an exception for staff:

PHP
<?php
// wp-content/plugins/myplugin/includes/ajax-public.php
function myplugin_ajax_cancel_booking(): void {
    check_ajax_referer( 'myplugin_cancel_booking', 'nonce' );

    global $wpdb;

    $booking_id = isset( $_POST['booking_id'] ) ? absint( wp_unslash( $_POST['booking_id'] ) ) : 0;
    $owner_id   = (int) $wpdb->get_var(
        $wpdb->prepare( 'SELECT user_id FROM %i WHERE id = %d', myplugin_bookings_table(), $booking_id )
    );

    $is_owner = $owner_id && get_current_user_id() === $owner_id;

    if ( ! $is_owner && ! current_user_can( 'myplugin_manage_bookings' ) ) {
        wp_send_json_error( array( 'message' => __( 'Booking not found.', 'myplugin' ) ), 404 );
    }

    myplugin_set_booking_status( $booking_id, 'cancelled' );

    wp_send_json_success();
}

The error says "not found" rather than "not yours", so the response does not confirm which booking IDs exist.

Step 3: did they mean to?

Every request that changes state needs a nonce whose action names the operation, and the object ID where there is one. Check:

  • Forms use wp_nonce_field(); handlers use check_admin_referer() or wp_verify_nonce().
  • AJAX uses check_ajax_referer(); REST relies on the wp_rest nonce and the permission callback.
  • No state change happens on a plain GET link without a nonce.
  • Nonce checks run before any write, not after.

Step 4: input

For every value read from a request:

  • Read keys one at a time; never pass $_POST whole into a function that saves or uses it.
  • wp_unslash(), then the sanitise function for the type, then validate: allowlists for fixed sets, ranges for numbers, get_post() and a post type check for IDs.
  • Never call extract() on request data, and never use request data as a function name, option name or meta key without an allowlist.
  • Never unserialize() data a user can influence, including cookies and imported files. Use JSON. PHP object injection through unserialize() has caused serious plugin vulnerabilities.

Step 5: output

Escape at the point of output, for the context, including data from your own database and from settings. Review especially:

  • Admin pages that print stored values: stored XSS often lands in the admin, where the victim is an administrator.
  • Shortcode attributes and block attributes, which contributors control.
  • Error messages that echo the submitted value.
  • Data passed to JavaScript: use wp_json_encode(), and textContent rather than innerHTML on the JavaScript side.

Step 6: SQL

Search for $wpdb-> and check every query:

  • Every variable goes through $wpdb->prepare(), with unquoted placeholders.
  • Table and column names come from code or go through %i.
  • ORDER BY and sort direction come from allowlists.
  • LIKE values use $wpdb->esc_like().
  • IN lists use one placeholder per value.

sanitize_text_field() before a query is not protection. Only prepare() is.

Step 7: files

File handling vulnerabilities are rarer but more severe, often leading to remote code execution.

Uploads. Use WordPress's upload handling with an explicit list of allowed types, never move_uploaded_file() to a path of your choosing:

PHP
<?php
// wp-content/plugins/myplugin/admin/import.php, inside a handler that has checked capability and nonce.
require_once ABSPATH . 'wp-admin/includes/file.php';

$upload = wp_handle_upload(
    $_FILES['myplugin_programme'] ?? array(),
    array(
        'test_form' => false,
        'mimes'     => array( 'pdf' => 'application/pdf' ),
    )
);

if ( isset( $upload['error'] ) ) {
    wp_die( esc_html( $upload['error'] ) );
}

wp_handle_upload() checks the file's real type against the allowed list, gives it a safe unique name and stores it in the uploads folder. An attacker who uploads shell.php renamed to programme.pdf is rejected.

Paths. Never build an include, require, file_get_contents() or unlink() path from request data. If you must accept a name, map it through an allowlist, or check it with validate_file(), which returns 0 for a safe relative path and a non-zero code for paths containing .. or drive letters.

Writing files. Prefer the database. If the plugin must write files, write inside wp_upload_dir() and never with an extension the server will execute.

Step 8: redirects and outgoing requests

  • Redirect with wp_safe_redirect(), which only allows the site's own host and those added with the allowed_redirect_hosts filter. wp_redirect() with a user-supplied URL is an open redirect, useful for phishing.
  • When the plugin fetches a URL a user supplied, such as an iCal feed, use wp_safe_remote_get(). It refuses local and private network addresses, which stops server-side request forgery against internal services.

Step 9: what the plugin reveals

  • REST responses and AJAX responses return only fields the caller may see. A __return_true permission callback is fine for public data only.
  • Error messages do not reveal paths, SQL or whether a user exists.
  • Every PHP file starts with the ABSPATH guard.
  • Secrets such as API keys are not printed into pages, passed to JavaScript, or stored in autoloaded options that every page loads. Where possible, read them from constants defined in wp-config.php.
  • No eval(), no shell commands such as exec() or shell_exec(), and no code built from strings at runtime.

Running the review

Turn the steps into a checklist in the repository and run it before each release. Automate what you can: the coding standards lesson adds PHPCS rules that flag missing escaping, unprepared queries and missing nonce checks, which catches most regressions before review. Automated tools miss access control bugs, though, so the entry point list in step 1 still needs a person.

When a security researcher reports a vulnerability, fix it quickly, release, and credit them. Plugins on WordPress.org that do not respond to reports can be closed by the plugin review team until they are fixed.

Summary

  • Start by listing every entry point: admin pages, admin-post, AJAX, REST, shortcodes, blocks, request-reading hooks, jobs and uploads.
  • Check the specific capability for the specific object; watch for insecure direct object references.
  • Nonces on every state change, checked before any write.
  • Sanitise and validate every input key; never unserialize() or extract() request data.
  • Escape late everywhere, especially stored values shown to administrators.
  • prepare() every query; handle uploads with wp_handle_upload() and allowed types; never build file paths from input.
  • Use wp_safe_redirect() and wp_safe_remote_get(), and reveal nothing the caller should not see.

Further reading