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 point | How to find it | Reachable by |
|---|---|---|
| Admin pages and their forms | add_menu_page, add_submenu_page, load- hooks | Users with the menu capability |
admin-post.php handlers | admin_post_ and admin_post_nopriv_ | Logged-in users; anyone for nopriv |
| AJAX handlers | wp_ajax_ and wp_ajax_nopriv_ | Every logged-in user; anyone for nopriv |
| REST routes | register_rest_route | Whatever the permission_callback allows |
| Shortcodes and blocks | add_shortcode, register_block_type | Anyone who can write a post |
Hooks on init, admin_init, template_redirect that read $_GET or $_POST | Search for superglobals | Anyone, on every request |
| Cron and Action Scheduler callbacks | Scheduled hooks | Indirectly, through data they process |
| File uploads and imports | $_FILES, wp_handle_upload | Whoever can reach the form |
A quick way to build the list is to search the codebase:
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 everyadmin-ajax.phprequest.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
// 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
// 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 usecheck_admin_referer()orwp_verify_nonce(). - AJAX uses
check_ajax_referer(); REST relies on thewp_restnonce 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
$_POSTwhole 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 throughunserialize()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(), andtextContentrather thaninnerHTMLon 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 BYand sort direction come from allowlists.LIKEvalues use$wpdb->esc_like().INlists 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
// 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 theallowed_redirect_hostsfilter.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_truepermission 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
ABSPATHguard. - 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 asexec()orshell_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()orextract()request data. - Escape late everywhere, especially stored values shown to administrators.
prepare()every query; handle uploads withwp_handle_upload()and allowed types; never build file paths from input.- Use
wp_safe_redirect()andwp_safe_remote_get(), and reveal nothing the caller should not see.