Skip to content

WordPress Plugin Security: Lessons from Two Disclosures

A

Al Amin Ahamed

Senior Engineer

10 min read
𝕏 in

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:

  1. Acknowledge within 24 hours
  2. Confirm the issue (or push back with evidence if it's not real)
  3. Ship a fix within the disclosure window (usually 90 days)
  4. Credit the researcher in the changelog
  5. 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:

  • phpcs with WordPress-Extra — flags unescaped output, unprepared queries, missing nonce checks
  • phpstan level 6+ — catches type confusion that often hides security bugs
  • semgrep with 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.

Share 𝕏 in
A

Al Amin Ahamed

Senior software engineer & AI practitioner. Laravel, PHP, WordPress plugins, WooCommerce extensions.

About me →

One email a month. No noise.

What I shipped, what I read, occasional deep dive. Unsubscribe anytime.