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
- 2. Unescaped Output (XSS)
- 3. Direct Database Queries Without prepare()
- 4. Missing Capability Checks
- 5. Unsanitized Input from $_POST/$_GET
- 6. Hardcoded API Keys and Secrets
- 7. Missing Direct File Access Prevention
- 8. PHP Compatibility Issues
- 9. Vulnerable Dependencies
- 10. Missing Text Domain in Translation Functions
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:
| Function | Use Case | Example |
|---|---|---|
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:
%dfor integers%sfor strings%ffor 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:
- Variables are not concatenated directly into the query string
$wpdb->prepare()is used with correct placeholder syntaxesc_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 siteedit_posts/delete_posts— manage postsedit_pages/delete_pages— manage pagesedit_users— manage usersmanage_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:
wp_unslash()— remove magic quotes (if present)- 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 Type | Function | Example |
|---|---|---|
| Text (single line) | sanitize_text_field() | User name, title |
| Text (multiline) | sanitize_textarea_field() | Bio, description |
sanitize_email() | Email address | |
| URL | esc_url_raw() | External URL |
| Integer | absint() or intval() | Post ID, count |
| File path | sanitize_file_name() | Uploaded file |
| Color hex | sanitize_hex_color() | Hex color code |
| Rich HTML | wp_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:
| Feature | PHP Version | Example |
|---|---|---|
| Named arguments | 8.0 | function_name(arg: $value) |
| Match expressions | 8.0 | match($x) { 1 => 'one' } |
| Constructor property promotion | 8.0 | public function __construct(public $prop) |
| Nullsafe operator | 8.0 | $obj?->method() |
| Enums | 8.1 | enum Status { case Active; } |
| Readonly properties | 8.1 | public readonly string $prop |
| Fibers | 8.1 | new Fiber() |
| Attributes | 8.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:
| Function | Purpose |
|---|---|
__() | 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:
- Compliance: WordPress.org plugin review requires text domains. Plugins without them are rejected.
- 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:
- PHPCS analysis — WordPress coding standards
- PHPStan type safety — Type errors and logic bugs
- Wordfence CVE cross-reference — Known vulnerability matching
- Composer audit — Dependency vulnerabilities
- Security function detection — Missing nonces, escaping, sanitization
- Hardcoded credentials — 22 secret patterns
- PHP compatibility — Version mismatches
- Direct file access — Missing ABSPATH guards
- Capability checks — Access control verification
- 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.