I've shipped five WordPress plugins and had two security disclosures against them. Both were embarrassing in retrospect — neither would have been caught by my tests. Security requires a different mindset than functional correctness.
Here's the security checklist I now run every plugin through before release.
The Threat Model
A WordPress plugin runs in a hostile environment:
- The site has multiple users with different roles (admin, editor, subscriber)
- The site has other plugins, some of which are vulnerable
- The site is publicly accessible and bots probe it constantly
- The user with the lowest privileges (subscriber) is the most likely attacker
Your defences must assume: a logged-in subscriber is sending crafted requests directly to your AJAX endpoints.
1. Capability Checks on Every Action
Every AJAX action, REST endpoint, and admin page needs an explicit capability check.
// Bad — anyone can trigger this
add_action('wp_ajax_my_plugin_delete_item', 'my_plugin_delete_item');
function my_plugin_delete_item() {
$id = (int) $_POST['id'];
delete_post($id);
}
Disclosed bug pattern: privilege escalation. A subscriber sends a POST to admin-ajax.php?action=my_plugin_delete_item&id=42 and deletes posts they don't own.
// Good
function my_plugin_delete_item() {
if (! current_user_can('delete_posts')) {
wp_send_json_error(array('message' => 'Forbidden'), 403);
}
check_ajax_referer('my_plugin_delete', '_wpnonce');
$id = (int) ($_POST['id'] ?? 0);
if (! current_user_can('delete_post', $id)) {
wp_send_json_error(array('message' => 'Forbidden'), 403);
}
wp_delete_post($id, true);
wp_send_json_success();
}
Three checks: general capability, nonce verification, per-object capability. The per-object check (delete_post, not delete_posts) catches the case where a user can delete their own posts but not someone else's.
2. Escape on Output, Always
XSS is the most common WordPress plugin vulnerability. The fix is mechanical: use the right escape function for the context.
// HTML body
echo esc_html( $user_input );
// HTML attribute
echo '<div data-id="' . esc_attr( $id ) . '">';
// URLs
echo '<a href="' . esc_url( $link ) . '">';
// JavaScript variables
echo '<script>var data = ' . wp_json_encode( $data ) . ';</script>';
// Translatable text
echo esc_html__( 'Welcome', 'my-plugin' );
// SQL-like contexts
$wpdb->prepare( 'WHERE id = %d', $id );
There is no "safe by default". WordPress will not escape for you. Even data from get_option() should be escaped on output — an admin notice with unescaped option data has been the source of several disclosures.
My disclosed XSS was an unescaped admin notice that interpolated $_GET['error_message'] directly. A subscriber could trick an admin into clicking a link that ran arbitrary JavaScript with admin privileges. Five lines of code, two years in production.
3. Nonces on Every Form
Nonces verify that a request originated from a form on your site (not a CSRF attack).
// In the form
<form method="post">
<?php wp_nonce_field('my_plugin_save_settings', '_wpnonce'); ?>
<!-- ... -->
</form>
// In the handler
if (! isset($_POST['_wpnonce']) || ! wp_verify_nonce($_POST['_wpnonce'], 'my_plugin_save_settings')) {
wp_die('Security check failed.');
}
For AJAX, use check_ajax_referer(). For REST, use the built-in nonce system or application passwords.
4. Sanitize on Input, Validate Before Use
Sanitization removes malicious content; validation rejects invalid content. Do both.
// Sanitize
$email = sanitize_email( $_POST['email'] ?? '' );
$slug = sanitize_title( $_POST['slug'] ?? '' );
$html = wp_kses_post( $_POST['content'] ?? '' );
$id = absint( $_POST['id'] ?? 0 );
// Validate
if (! is_email($email)) {
wp_send_json_error(array('message' => 'Invalid email'));
}
if ($id === 0 || ! get_post($id)) {
wp_send_json_error(array('message' => 'Post not found'));
}
Never pass $_POST data to a database write without sanitizing. Never trust that sanitization caught everything — validate semantic correctness too.
5. Use $wpdb->prepare() for Every Query
// Bad — SQL injection
$wpdb->query("SELECT * FROM wp_posts WHERE post_author = " . $_GET['author']);
// Good
$wpdb->query(
$wpdb->prepare("SELECT * FROM wp_posts WHERE post_author = %d", $_GET['author'])
);
Even if you're "sure" the value is safe, prepare it. The CI checker phpcs with WordPress-Extra flags unprepared queries — fail the build on these.
6. File Upload Validation
$file = $_FILES['upload'] ?? null;
if (! $file) {
return;
}
// Validate MIME type
$allowed = array('image/jpeg', 'image/png', 'image/webp');
$type = wp_check_filetype($file['name']);
if (! in_array($type['type'], $allowed, true)) {
wp_send_json_error(array('message' => 'Invalid file type'));
}
// Use WP's hardened uploader
$upload = wp_handle_upload($file, array('test_form' => false));
if (isset($upload['error'])) {
wp_send_json_error(array('message' => $upload['error']));
}
wp_handle_upload() strips PHP from filenames, checks MIME against extension, and writes to the secure uploads directory. Don't reinvent it.
7. Disable Direct File Access
Every plugin file should start with:
defined('ABSPATH') || exit;
Without this, an attacker can hit /wp-content/plugins/my-plugin/lib/dangerous.php directly and trigger code paths that assume WordPress is loaded.
8. Treat Disclosures as Gifts
When a researcher reports a vulnerability:
- Acknowledge within 24 hours
- Confirm the issue (or push back with evidence if it's not real)
- Ship a fix within the disclosure window (usually 90 days)
- Credit the researcher in the changelog
- File a CVE if the bug warrants it (medium severity or above)
The first time it happened to me, my instinct was to be defensive. That was wrong. The researcher saved my users from getting compromised — they're on my side.
CI Tooling
Run these on every PR:
phpcswithWordPress-Extra— flags unescaped output, unprepared queries, missing nonce checksphpstanlevel 6+ — catches type confusion that often hides security bugssemgrepwith WordPress rules — catches known patterns the others miss
All three together take < 30 seconds per build and catch ~80% of common security mistakes before review.
Al Amin Ahamed
Senior software engineer & AI practitioner. Laravel, PHP, WordPress plugins, WooCommerce extensions.
About me →More from the blog
← Older
Tailwind v4 Migration: From v3 Config to CSS-First Tokens
Newer →
Building Custom Commission Logic in Dokan Multi-Vendor
One email a month. No noise.
What I shipped, what I read, occasional deep dive. Unsubscribe anytime.
Check your inbox — confirmation link sent.