Skip to main content
WP HealthKit

Top 10 WordPress Plugin Security Mistakes to Avoid

March 26, 202620 min readSecurityBy Jamie

Every week, WP HealthKit audits hundreds of WordPress plugins and identifies hundreds of security mistakes that could lead to data breaches, malware infections, and site compromise. We've reviewed the security mistakes across thousands of plugins and distilled the findings into this guide. These are the 10 most critical vulnerabilities we discover during WordPress plugins audits — complete with real CVE references, before-and-after PHP code, and explanation of how WP HealthKit detects each one across our 17 verification layers.

According to the Patchstack State of WordPress Security, there were over 333 new WordPress vulnerabilities reported in January 2026 alone — with plugin vulnerabilities accounting for the majority. This guide covers the root causes behind those breaches.

Table of Contents


1. Missing Nonce Verification on AJAX Handlers

Severity: Critical

OWASP Category: A01 — Cross-Site Request Forgery (CSRF)

The Problem

AJAX handlers are entry points into your plugin. Without nonce verification, attackers can forge requests from other websites and trick authenticated users into performing unintended actions. This was the vulnerability behind CVE-2024-10924 in the Really Simple Security plugin, which affected 4 million sites.

The vulnerable code:

add_action('wp_ajax_save_settings', 'handle_save_settings');
function handle_save_settings() {
    update_option('my_setting', $_POST['value']);
    wp_send_json_success();
}

An attacker crafts an HTML page that tricks a logged-in admin into visiting, triggering the AJAX call without their knowledge.

The Fix

Verify the nonce AND check user capabilities:

add_action('wp_ajax_save_settings', 'handle_save_settings');
function handle_save_settings() {
    check_ajax_referer('my_plugin_nonce', 'security');
    if (!current_user_can('manage_options')) {
        wp_send_json_error('Unauthorized', 403);
    }
    $value = sanitize_text_field(wp_unslash($_POST['value']));
    update_option('my_setting', $value);
    wp_send_json_success();
}

The nonce is passed from the frontend when the AJAX handler is set up:

wp_localize_script('my-script', 'myPlugin', [
    'nonce' => wp_create_nonce('my_plugin_nonce'),
]);

Then verified client-side:

data: {
    action: 'save_settings',
    security: myPlugin.nonce,
    value: 'new_value'
}

Nonces prove intent (the user intended to perform this action), while capabilities prove permission (the user is allowed to perform it). You must verify both.

Real-World Impact

Really Simple Security's authentication bypass allowed attackers to reset admin passwords on 4 million installations. The root cause: missing nonce validation on an AJAX endpoint. Thousands of sites were compromised before the patch was released.

How WP HealthKit Detects This

Our verification layer scans for wp_ajax_ hooks that lack a corresponding check_ajax_referer() or wp_verify_nonce() call in the handler function, and checks that current_user_can() is called to verify permissions.


2. Unescaped Output (XSS)

Severity: Critical

OWASP Category: A07 — Cross-Site Scripting (XSS)

The Problem

Stored and reflected XSS are the most prevalent vulnerabilities in WordPress plugins. When you output user-supplied or dynamic data without escaping, attackers inject malicious JavaScript that executes in the browsers of all site visitors.

CVE-2025-12450 in LiteSpeed Cache exposed 7 million sites to XSS attacks. The vulnerability allowed authenticated users to inject JavaScript that would execute for all site visitors, potentially stealing data or compromising security.

Simple unescaped output:

echo '<input value="' . $user_input . '">';
echo '<p>' . $post_data . '</p>';
echo '<a href="' . $url . '">Link</a>';

Any of these will execute JavaScript if the variables contain malicious content.

The Fix

Use context-appropriate escaping functions:

echo '<input value="' . esc_attr($user_input) . '">';
echo '<p>' . esc_html($post_data) . '</p>';
echo '<a href="' . esc_url($url) . '">Link</a>';

WordPress provides five primary escaping functions:

FunctionUse CaseExample
esc_html()Text content in HTML<p><?php echo esc_html($text); ?></p>
esc_attr()HTML attributes<input value="<?php echo esc_attr($val); ?>">
esc_url()URLs in href, src<a href="<?php echo esc_url($link); ?>">
esc_js()Inline JavaScript strings<script>var x = "<?php echo esc_js($str); ?>";</script>
wp_kses_post()Rich content with allowed HTML<?php echo wp_kses_post($content); ?>

When echoing in JavaScript, always use esc_js():

<script>
var settings = <?php echo wp_json_encode($settings); ?>;
var message = "<?php echo esc_js($message); ?>";
</script>

If you need to allow some HTML (like paragraphs or links), use wp_kses_post() but never allow script, style, or object tags.

Real-World Impact

The LiteSpeed Cache XSS reached 7 million sites — demonstrating how a single escaping oversight can affect the entire ecosystem. Attackers exploited it to inject malware payloads and steal session tokens.

According to security analysis of WordPress CVEs in 2025, over 50% of plugin CVEs are XSS-related. It's the easiest vulnerability to introduce and the hardest to detect manually.

How WP HealthKit Detects This

Our scanner uses static analysis to find echo, print, and return statements that output variables without escaping. We check for the presence of escape functions and verify they're the correct function for the context.


3. Direct Database Queries Without prepare()

Severity: Critical

OWASP Category: A03 — Injection

The Problem

SQL injection happens when user input is concatenated directly into database queries. Attackers can break out of the query and execute arbitrary SQL, stealing data, modifying tables, or dropping databases.

The Forminator Forms plugin (CVE-2025-7638) was vulnerable to SQL injection and affected 600,000 sites. The vulnerability allowed attackers to read sensitive form submission data.

Vulnerable code:

$user_id = $_GET['id'];
$result = $wpdb->query("DELETE FROM {$wpdb->posts} WHERE ID = {$user_id}");

If an attacker passes id=1 OR 1=1, the query becomes DELETE FROM posts WHERE ID = 1 OR 1=1 — deleting all posts.

The Fix

Always use $wpdb->prepare() with placeholders:

$user_id = absint($_GET['id']);
$result = $wpdb->query($wpdb->prepare(
    "DELETE FROM {$wpdb->posts} WHERE ID = %d",
    $user_id
));

Placeholders:

  • %d for integers
  • %s for strings
  • %f for floats

For LIKE queries, use $wpdb->esc_like() first:

$search = $_GET['search'];
$search = $wpdb->esc_like($search);
$result = $wpdb->get_results($wpdb->prepare(
    "SELECT * FROM {$wpdb->posts} WHERE post_title LIKE %s",
    '%' . $search . '%'
));

Never interpolate table or column names — only values go through placeholders:

// Correct
$wpdb->get_results($wpdb->prepare(
    "SELECT * FROM {$wpdb->posts} WHERE ID = %d",
    $id
));

// Wrong — interpolating the column name
$wpdb->get_results($wpdb->prepare(
    "SELECT * FROM {$wpdb->posts} WHERE {$column} = %s",
    $value
));

If you need dynamic column names, whitelist them:

$allowed_columns = ['post_title', 'post_content', 'post_author'];
$column = isset($_GET['column']) ? $_GET['column'] : 'post_title';
$column = in_array($column, $allowed_columns) ? $column : 'post_title';

$result = $wpdb->get_results($wpdb->prepare(
    "SELECT * FROM {$wpdb->posts} WHERE {$column} = %s",
    $value
));

Real-World Impact

SQL injection remains the #2 attack vector after XSS. Unlike XSS (which affects end users), SQLi compromises the entire database. An attacker can extract customer data, modify settings, or insert malware.

The Forminator vulnerability affected 600,000 sites that use forms to collect sensitive information — all exposed to read attacks.

How WP HealthKit Detects This

We scan for $wpdb->query(), $wpdb->get_results(), and $wpdb->get_row() calls. We check that:

  1. Variables are not concatenated directly into the query string
  2. $wpdb->prepare() is used with correct placeholder syntax
  3. esc_like() is used for LIKE clauses with variable data

4. Missing Capability Checks

Severity: Critical

OWASP Category: A01 — Broken Access Control

The Problem

Many plugins verify nonces but forget to check if the user actually has permission to perform the action. This is privilege escalation — a lower-privileged user can perform actions reserved for administrators.

The Bricks theme (CVE-2024-25600) was vulnerable because it verified nonces but failed to check user capabilities on several AJAX endpoints, allowing subscribers to modify site settings.

Vulnerable code:

add_action('wp_ajax_delete_custom_post', 'delete_post');
function delete_post() {
    check_ajax_referer('my_nonce');
    wp_delete_post($_POST['post_id']);
    wp_send_json_success();
}

Any logged-in user can delete posts, even if they lack permission.

The Fix

Always check capabilities after verifying the nonce:

add_action('wp_ajax_delete_custom_post', 'delete_post');
function delete_post() {
    check_ajax_referer('my_nonce');
    
    if (!current_user_can('delete_posts')) {
        wp_send_json_error('Forbidden', 403);
    }
    
    $post_id = absint($_POST['post_id']);
    wp_delete_post($post_id);
    wp_send_json_success();
}

For custom post types:

if (!current_user_can('delete_custom_posts')) {
    wp_send_json_error('Forbidden', 403);
}

WordPress has granular capabilities:

  • manage_options — administrate the site
  • edit_posts / delete_posts — manage posts
  • edit_pages / delete_pages — manage pages
  • edit_users — manage users
  • manage_plugins — install/update plugins

Always use the most restrictive capability required for the action.

Understanding the Difference

Nonce: A one-time token that proves the user intended to perform this action (CSRF protection). It has a 12-24 hour lifetime.

Capability: A permission that proves the user is allowed to perform this action (access control). It's persistent.

You must verify both. A nonce alone proves intent but not permission. A capability alone proves permission but not intent (vulnerable to CSRF).

Real-World Impact

The Bricks vulnerability allowed subscribers to modify site settings, change admin passwords, and install plugins — effectively becoming site administrators.

How WP HealthKit Detects This

We scan for AJAX handlers, REST endpoints, and admin pages that lack current_user_can() checks. We flag actions that require higher capabilities but only check lower-privilege capabilities.


5. Unsanitized Input from $_POST/$_GET

Severity: High

OWASP Category: A06 — Vulnerable and Outdated Components

The Problem

Every value from $_POST, $_GET, $_COOKIE, and $_SERVER['HTTP_*'] must be sanitized. Unsanitized input can lead to XSS, SQLi, path traversal, and other injection attacks.

The correct order is always:

  1. wp_unslash() — remove magic quotes (if present)
  2. Appropriate sanitize_*() function — enforce expected format
// Bad — no sanitization
$username = $_POST['username'];

// Better — sanitize but not unslashed first
$username = sanitize_text_field($_POST['username']);

// Best — unslash first, then sanitize
$username = sanitize_text_field(wp_unslash($_POST['username']));

The Fix

Use the correct sanitization function for each data type:

Data TypeFunctionExample
Text (single line)sanitize_text_field()User name, title
Text (multiline)sanitize_textarea_field()Bio, description
Emailsanitize_email()Email address
URLesc_url_raw()External URL
Integerabsint() or intval()Post ID, count
File pathsanitize_file_name()Uploaded file
Color hexsanitize_hex_color()Hex color code
Rich HTMLwp_kses_post()Post content

Examples:

$username = sanitize_text_field(wp_unslash($_POST['username']));
$bio = sanitize_textarea_field(wp_unslash($_POST['bio']));
$email = sanitize_email(wp_unslash($_POST['email']));
$url = esc_url_raw(wp_unslash($_POST['website']));
$post_id = absint($_POST['post_id']);
$filename = sanitize_file_name($_FILES['upload']['name']);
$color = sanitize_hex_color(wp_unslash($_POST['color']));
$content = wp_kses_post(wp_unslash($_POST['content']));

For arrays, sanitize each value:

$tags = array_map('sanitize_text_field', wp_unslash($_POST['tags']));

Real-World Impact

Unsanitized input is the vector for most injection attacks. It's low-effort to exploit and affects the entire site.

How WP HealthKit Detects This

We scan for direct usage of $_POST, $_GET, and $_SERVER['HTTP_*'] without a sanitization function. We verify the correct sanitization function is used for the data type.


6. Hardcoded API Keys and Secrets

Severity: Critical

OWASP Category: A02 — Cryptographic Failures

The Problem

Hardcoded credentials in source code can be extracted by anyone with access to the repository — including attackers who fork your plugin from GitHub or access backups. CVE-2024-10284 in the CE21 Suite plugin contained a hardcoded encryption key that allowed attackers to decrypt sensitive customer data.

WP HealthKit's secret scanner detects 22 types of credentials:

  • AWS keys (AKIA...)
  • Stripe API keys (sk_live_, pk_live_)
  • GitHub tokens (ghp_...)
  • SendGrid API keys
  • Mailgun keys
  • Database passwords
  • Custom API keys

Vulnerable code:

// Bad — hardcoded key
$api_key = 'sk_live_abc123xyz';
$client = new StripeClient($api_key);

The Fix

Store secrets in environment variables or WordPress options:

// Good — constants in wp-config.php
define('MY_PLUGIN_STRIPE_KEY', 'sk_live_abc123xyz');

// Good — WordPress options (admin-only)
$api_key = defined('MY_PLUGIN_STRIPE_KEY') 
    ? MY_PLUGIN_STRIPE_KEY 
    : get_option('my_plugin_stripe_key');

// Good — environment variable
$api_key = getenv('MY_PLUGIN_STRIPE_KEY');

In wp-config.php:

define('MY_PLUGIN_STRIPE_KEY', 'sk_live_abc123xyz');
define('MY_PLUGIN_DATABASE_PASSWORD', 'secure_password');

Use a .env file for development (never commit to version control):

MY_PLUGIN_STRIPE_KEY=sk_live_abc123xyz
MY_PLUGIN_API_URL=https://api.example.com

For plugin settings, store in options table (encrypted at rest in managed hosting):

add_option('my_plugin_api_key', 'sk_live_abc123xyz', '', 'no');
$api_key = get_option('my_plugin_api_key');

Add to .gitignore:

.env
.env.local
wp-config-local.php

Real-World Impact

The CE21 Suite vulnerability exposed customer data across thousands of installations. Once a secret is committed to GitHub, it's in the public internet forever — even if you delete the commit.

How WP HealthKit Detects This

Our secret scanner uses 22 patterns to identify hardcoded credentials. It detects API key prefixes, cryptographic keys, database passwords, and JWT tokens across all PHP files.


7. Missing Direct File Access Prevention

Severity: Medium

OWASP Category: A01 — Broken Access Control

The Problem

Every PHP file in your plugin should prevent direct HTTP access. Without this guard, attackers can load your plugin files directly in a browser, potentially triggering errors that reveal file paths or sensitive information.

Vulnerable plugin files:

/wp-content/plugins/my-plugin/includes/class-settings.php
/wp-content/plugins/my-plugin/admin/ajax-handler.php

Accessed directly as:

http://example.com/wp-content/plugins/my-plugin/includes/class-settings.php

This can trigger:

  • Fatal errors revealing file paths
  • Function definitions (information disclosure)
  • Database connection attempts
  • Partial execution of initialization code

The Fix

Add this guard at the top of every PHP file:

<?php
if (!defined('ABSPATH')) {
    exit;
}

// Rest of your plugin code

ABSPATH is defined by WordPress in wp-load.php (loaded by wp-config.php). If the file is accessed directly, ABSPATH won't be defined and the script exits immediately.

Example for a plugin file:

<?php
/**
 * Plugin settings handler
 *
 * @package MyPlugin
 */

if (!defined('ABSPATH')) {
    exit;
}

class Settings {
    public function __construct() {
        // ... initialization
    }
}

Real-World Impact

While less critical than injection vulnerabilities, direct file access can leak sensitive information — file paths, configuration details, or error messages that aid further attacks.

How WP HealthKit Detects This

We scan every PHP file in the plugin for the ABSPATH guard. Files that lack it are flagged as misconfigured.


8. PHP Compatibility Issues

Severity: High

OWASP Category: N/A (Code Quality)

The Problem

Declaring Requires PHP: 7.4 but using PHP 8.2 features like enums will crash on servers running PHP 7.4. This isn't a security vulnerability per se, but it causes data loss, broken functionality, and increases attack surface if error handling fails.

PHP 8.0+ introduced several new features that break backward compatibility:

FeaturePHP VersionExample
Named arguments8.0function_name(arg: $value)
Match expressions8.0match($x) { 1 => 'one' }
Constructor property promotion8.0public function __construct(public $prop)
Nullsafe operator8.0$obj?->method()
Enums8.1enum Status { case Active; }
Readonly properties8.1public readonly string $prop
Fibers8.1new Fiber()
Attributes8.0#[Route('/path')]

The Fix

Check your plugin.php header:

<?php
/**
 * Plugin Name: My Plugin
 * Plugin URI: https://example.com
 * Description: A secure plugin
 * Version: 1.0.0
 * Author: Your Name
 * Requires PHP: 8.0
 * License: GPL v2 or later
 */

If you require PHP 7.4 support, don't use PHP 8.0+ syntax:

// PHP 7.4 — safe
$method = $obj->getMethod();
return $method();

// PHP 8.0+ — named arguments (not safe for PHP 7.4)
return $obj->process(status: 'active', priority: 1);

// PHP 7.4 compatible
return $obj->process('active', 1);

Match expressions aren't supported in PHP 7.4:

// PHP 8.0+ — match (not safe for PHP 7.4)
$label = match($status) {
    'active' => 'Active',
    'inactive' => 'Inactive',
};

// PHP 7.4 compatible
$label = $status === 'active' ? 'Active' : 'Inactive';

Real-World Impact

Mismatched PHP versions cause white screens of death, crashed AJAX handlers, and broken admin panels. This creates attack surface — if error handling is misconfigured, sensitive details leak.

How WP HealthKit Detects This

Our PHP compatibility scanner parses all PHP files and detects modern syntax features (enums, match expressions, named arguments, readonly properties, fibers, attributes, nullsafe operators) and reports incompatibility with declared minimum PHP version.


9. Vulnerable Dependencies

Severity: High

OWASP Category: A06 — Vulnerable and Outdated Components

The Problem

If your plugin uses Composer packages, your composer.lock file may contain packages with known CVEs. In January 2026, there were 333 new WordPress vulnerabilities reported — many in dependencies.

Vulnerabilities in dependencies (transitive dependencies) can be subtle. You might depend on a package that depends on another package with a vulnerable version.

Check your dependencies:

$ composer audit

This checks all packages and their dependencies against Packagist security advisories.

The Fix

Update packages regularly:

composer update
composer audit

Add to your deployment pipeline:

composer audit --exit-code=1

For critical vulnerabilities, update immediately:

composer update vendor/package

Keep your lock file in version control:

composer.lock  # check in
vendor/        # add to .gitignore

This ensures consistent versions across environments.

Real-World Impact

Vulnerable dependencies are the #3 attack vector after XSS and SQLi. In 2025, over 333 vulnerabilities per week were discovered in the WordPress ecosystem — many in dependencies.

Example: A plugin depends on a logging library version 1.0, which has a vulnerability. The plugin developer hasn't updated in 2 years. When an attacker exploits the logging library, the plugin is compromised even though the plugin code is secure.

How WP HealthKit Detects This

We parse composer.json and composer.lock files, extract all dependencies, and check them against Packagist security advisories. We also flag transitive dependencies that have vulnerabilities, even if they're not listed as direct dependencies.


10. Missing Text Domain in Translation Functions

Severity: High

OWASP Category: N/A (Compliance)

The Problem

WordPress translation functions require a text domain for translations to work. Missing text domains break i18n support and fail WordPress.org plugin review.

The text domain must match your plugin slug and be passed to every translation function.

Vulnerable code:

// Bad — no text domain
echo __('Welcome to my plugin');
echo _e('Save settings');
$label = __('Active');

These strings won't be translatable because they lack a domain.

The Fix

Always include the text domain as the second parameter:

// Good — with text domain
echo __('Welcome to my plugin', 'my-plugin');
echo _e('Save settings', 'my-plugin');
$label = __('Active', 'my-plugin');

// Best — escaped and with text domain
echo esc_html__('Welcome to my plugin', 'my-plugin');
echo esc_html_e('Save settings', 'my-plugin');

The text domain must match your plugin slug:

/wp-content/plugins/my-plugin/
                     ^^^^^^^^
                     text domain

Use escape variants for i18n:

FunctionPurpose
__()Get translated string
_e()Echo translated string
esc_html__()Get translated string, escaped
esc_html_e()Echo translated string, escaped
_x()Get translated string with context

Example with context:

// 'Settings' in the context of 'admin' vs 'user'
$label = _x('Settings', 'admin', 'my-plugin');

In your plugin main file:

<?php
/**
 * Plugin Name: My Plugin
 * Text Domain: my-plugin
 * Domain Path: /languages
 */

add_action('init', function() {
    load_plugin_textdomain(
        'my-plugin',
        false,
        dirname(plugin_basename(__FILE__)) . '/languages'
    );
});

Real-World Impact

Missing text domains cause two problems:

  1. Compliance: WordPress.org plugin review requires text domains. Plugins without them are rejected.
  2. Functionality: Translators can't translate your plugin because translation tools can't extract strings without a domain.

How WP HealthKit Detects This

We scan for __(), _e(), _x(), and related functions. We check that every call includes a text domain parameter and that the domain matches the plugin slug.


Found These Security Mistakes in Your Plugin?

WP HealthKit checks for all 10 of these patterns and 40+ more in under 5 minutes. Our scanner uses static analysis to detect vulnerabilities before they reach users.

Run a free audit — your first 3 audits are free, no credit card required.

For plugin maintainers, join our Open Source Program for unlimited free audits and priority support.

Browse the Plugin Directory to see how other plugins score and view detailed security breakdowns.


Check Your Plugin Security

Ready to audit your plugin? WP HealthKit scans across 17 verification layers:

  1. PHPCS analysis — WordPress coding standards
  2. PHPStan type safety — Type errors and logic bugs
  3. Wordfence CVE cross-reference — Known vulnerability matching
  4. Composer audit — Dependency vulnerabilities
  5. Security function detection — Missing nonces, escaping, sanitization
  6. Hardcoded credentials — 22 secret patterns
  7. PHP compatibility — Version mismatches
  8. Direct file access — Missing ABSPATH guards
  9. Capability checks — Access control verification
  10. Text domain validation — i18n compliance

Run a free audit now. Your first 3 audits are free, no credit card required. Most plugins complete in under 5 minutes.


Frequently Asked Questions

What is the most common WordPress plugin vulnerability?

Cross-Site Scripting (XSS) accounts for over 50% of all WordPress plugin CVEs in 2025. It's the easiest vulnerability to introduce because developers can forget to escape output in any of hundreds of places. Most plugins have at least one unescaped output vulnerability.

How do I check if my WordPress plugin is secure?

Use WP HealthKit for an automated scan that covers all 10 of these mistakes plus 40+ more patterns. Manually review your code against the WordPress Plugin Security Handbook. Run composer audit to check dependencies. Test with static analysis tools like PHPStan and PHPCS.

What is a nonce in WordPress?

A nonce (number used once) is a security token that prevents Cross-Site Request Forgery (CSRF) attacks. It's a one-time use token with a 12-24 hour lifetime that proves the user intended to perform an action from your site. Always use nonces with check_ajax_referer() or wp_verify_nonce() before processing form submissions or AJAX requests.

How often should I audit my WordPress plugin for security?

Audit before every major release (security-critical). Run quarterly audits at minimum (333 new vulnerabilities per week means new attack vectors constantly). Audit immediately if a dependency releases a security patch. Set up automated scanning with WP HealthKit in your CI/CD pipeline to catch issues before release.

Will these security issues fail a WordPress.org plugin review?

Yes. The WordPress.org plugin review team checks for:

  • Missing nonce verification on AJAX/form handlers (rejection)
  • Unescaped output (rejection)
  • Direct database queries without prepare() (rejection)
  • Missing text domains (rejection)
  • Direct file access vulnerabilities (rejection)

These are hard requirements. Plugins with any of these issues are rejected until fixed.


Audit Your Plugin Today

WP HealthKit checks for all 10 of these security mistakes and 40+ more across 17 verification layers — in under 5 minutes, with actionable remediation steps for every finding.

Run a free security audit → — No credit card required.

Ready to audit your plugin?

WP HealthKit checks for all the issues in this article and 40+ more across 49 verification layers.

Comments

Top 10 WordPress Plugin Security Mistakes to Avoid | WP HealthKit