
In security engineering, there are two kinds of plugins: those that have been attacked, and those that haven’t been attacked yet. When you’re building WordPress plugins used by millions of websites, the attack surface is immense — and adversaries are actively scanning for vulnerabilities.
This post covers the defensive engineering practices we apply at Brainstorm Force to harden plugins against zero-day exploits. These aren’t theoretical — they’re patterns that have caught real attack attempts on production plugins.
Before writing a single line of security code, understand what you’re defending against. WordPress plugin attacks generally fall into these categories:
Every piece of data from outside your plugin is hostile until proven otherwise. This includes:
$_GET, $_POST, $_REQUEST, $_COOKIE// WRONG — trusting user input directly
function save_settings() {
update_option('my_plugin_setting', $_POST['setting']);
}
// RIGHT — sanitize everything, validate context
function save_settings() {
if (!current_user_can('manage_options')) {
wp_die('Unauthorized', 403);
}
check_admin_referer('my_plugin_settings_nonce');
$setting = sanitize_text_field($_POST['setting'] ?? '');
// Additional business rule validation
if (!in_array($setting, ['option_a', 'option_b', 'option_c'], true)) {
wp_die('Invalid setting value', 400);
}
update_option('my_plugin_setting', $setting);
}
Capability checks are NOT optional. Every AJAX handler, REST endpoint, and admin form must verify the current user has permission to perform the action:
// REST endpoint example
class SettingsController extends WP_REST_Controller {
public function update_item_permissions_check($request): bool|WP_Error {
if (!current_user_can('manage_options')) {
return new WP_Error(
'rest_forbidden',
'You do not have permission to update settings.',
['status' => rest_authorization_required_code()]
);
}
return true;
}
}
// AJAX handler example
add_action('wp_ajax_my_plugin_action', function() {
// MUST check: nonce, capability, and context
if (!check_ajax_referer('my_plugin_nonce', '_wpnonce', false)) {
wp_send_json_error('Invalid security token', 403);
}
if (!current_user_can('edit_posts')) {
wp_send_json_error('Insufficient permissions', 403);
}
// Now process the request
});
Every database query with external data must use $wpdb->prepare(). SQL injection is still the most common plugin vulnerability:
global $wpdb;
// DANGEROUS — SQLi vulnerability
$results = $wpdb->get_results(
"SELECT * FROM {$wpdb->posts} WHERE post_title = '" . $_GET['title'] . "'"
);
// SAFE — prepared statement
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$wpdb->posts} WHERE post_title = %s AND post_type = %s",
sanitize_text_field($_GET['title']),
'post'
)
);
// For IN clauses — common pitfall
$ids = array_map('absint', $_POST['ids'] ?? []);
if (!empty($ids)) {
$placeholders = implode(',', array_fill(0, count($ids), '%d'));
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$wpdb->posts} WHERE ID IN ($placeholders)",
...$ids
)
);
}
WordPress AJAX is a common attack vector. Many vulnerabilities come from wp_ajax_nopriv_ handlers that process unauthenticated requests:
class AjaxHandler {
private const NONCE_ACTION = 'my_plugin_ajax';
public function __construct() {
// Authenticated actions only — no nopriv unless absolutely necessary
add_action('wp_ajax_my_plugin_search', [$this, 'handle_search']);
}
public function handle_search(): never {
try {
$this->verify_request();
$query = sanitize_text_field(wp_unslash($_POST['q'] ?? ''));
$post_type = sanitize_key($_POST['post_type'] ?? 'post');
// Whitelist post types — don't allow arbitrary type queries
$allowed_types = ['post', 'page', 'product'];
if (!in_array($post_type, $allowed_types, true)) {
wp_send_json_error('Invalid post type', 400);
}
$results = $this->search($query, $post_type);
wp_send_json_success($results);
} catch (SecurityException $e) {
wp_send_json_error($e->getMessage(), $e->getCode());
}
}
private function verify_request(): void {
if (!check_ajax_referer(self::NONCE_ACTION, '_nonce', false)) {
throw new SecurityException('Invalid nonce', 403);
}
if (!current_user_can('edit_posts')) {
throw new SecurityException('Insufficient permissions', 403);
}
}
}
XSS prevention requires escaping all dynamic data at the point of output. WordPress provides context-specific escaping functions — use the right one:
// HTML context — for content inside HTML tags
echo esc_html($user_input);
// HTML attribute context
echo '<input value="' . esc_attr($user_input) . '">';
// URL context
echo '<a href="' . esc_url($user_url) . '">Click</a>';
// JavaScript context — for inline JS
echo '<script>var data = ' . wp_json_encode($data) . ';</script>';
// CSS context
echo '<style>color: ' . esc_attr($color) . ';</style>';
// Translatable strings — use esc_html__ and esc_attr__
echo esc_html__('Hello, %s', 'my-plugin');
printf(esc_html__('Hello, %s', 'my-plugin'), esc_html($username));
// In Gutenberg blocks — sanitize block attributes on save
register_block_type('my-plugin/block', [
'attributes' => [
'title' => [
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
],
],
'render_callback' => function(array $attrs): string {
return sprintf(
'<div class="my-block">%s</div>',
esc_html($attrs['title'] ?? '')
);
},
]);
File uploads are a critical attack vector — attackers attempt to upload PHP files to gain code execution. Never trust the uploaded file’s MIME type header (it’s trivially spoofable):
function handle_file_upload(array $file): int|WP_Error {
// 1. Check file size
$max_size = 5 * 1024 * 1024; // 5MB
if ($file['size'] > $max_size) {
return new WP_Error('file_too_large', 'File exceeds maximum size of 5MB');
}
// 2. Validate MIME type using finfo (not the HTTP header)
$finfo = new finfo(FILEINFO_MIME_TYPE);
$detected = $finfo->file($file['tmp_name']);
$allowed_mimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (!in_array($detected, $allowed_mimes, true)) {
return new WP_Error(
'invalid_mime',
sprintf('File type %s is not allowed', esc_html($detected))
);
}
// 3. Validate extension matches MIME type
$extension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
$mime_to_ext = ['image/jpeg' => ['jpg','jpeg'], 'image/png' => ['png'], /* ... */];
$allowed_exts = $mime_to_ext[$detected] ?? [];
if (!in_array($extension, $allowed_exts, true)) {
return new WP_Error('extension_mismatch', 'File extension does not match content type');
}
// 4. Use WordPress media upload (handles everything else)
require_once ABSPATH . 'wp-admin/includes/image.php';
require_once ABSPATH . 'wp-admin/includes/file.php';
require_once ABSPATH . 'wp-admin/includes/media.php';
return media_handle_sideload([
'name' => sanitize_file_name($file['name']),
'tmp_name' => $file['tmp_name'],
'type' => $detected,
'error' => $file['error'],
'size' => $file['size'],
], 0);
}
Contact forms, login endpoints, and search APIs need rate limiting to prevent abuse:
class RateLimiter {
public function check(string $action, string $identifier, int $max, int $window): bool {
$key = "rl:{$action}:" . md5($identifier);
$current = (int) get_transient($key);
if ($current >= $max) {
return false; // Rate limited
}
if ($current === 0) {
set_transient($key, 1, $window);
} else {
// Increment without resetting TTL — use WP object cache for accuracy
wp_cache_incr($key, 1, 'rate_limit');
set_transient($key, $current + 1, $window);
}
return true;
}
}
// Usage in contact form handler
$limiter = new RateLimiter();
$ip = sanitize_text_field($_SERVER['REMOTE_ADDR'] ?? '');
if (!$limiter->check('contact_form', $ip, max: 5, window: 3600)) {
wp_send_json_error('Too many requests. Please try again in an hour.', 429);
}
add_action('send_headers', function() {
// Only on non-admin pages
if (is_admin()) return;
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: SAMEORIGIN');
header('Referrer-Policy: strict-origin-when-cross-origin');
header('Permissions-Policy: camera=(), microphone=(), geolocation=()');
// Content Security Policy — adjust to your needs
$csp = implode('; ', [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' https://cdn.trusted-service.com",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self'",
"connect-src 'self'",
"frame-ancestors 'none'",
]);
header("Content-Security-Policy: $csp");
});
Manual code review isn’t enough. Build security scanning into your CI/CD pipeline:
# .github/workflows/security.yml
name: Security Scan
on: [push, pull_request]
jobs:
phpcs-security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install PHP_CodeSniffer + Security Standards
run: |
composer require --dev squizlabs/php_codesniffer
composer require --dev phpcompatibility/php-compatibility
composer require --dev automattic/vipwpcs
composer require --dev wpscan/wpscan-rules 2>/dev/null || true
- name: Run Security Scan
run: |
./vendor/bin/phpcs --standard=WordPress-Security \
--extensions=php \
--ignore=vendor,node_modules \
.
semgrep:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: semgrep/semgrep-action@v1
with:
config: "p/wordpress p/php p/owasp-top-ten"
Security isn’t a feature you add at the end — it’s a discipline you practice throughout development. Every time you write a database query, render user content, or handle a form submission, you’re making a security decision.
The patterns in this post — capability checks, prepared statements, escaping on output, MIME validation, rate limiting — have prevented real attacks on plugins reaching millions of WordPress sites. They’re not optional best practices; they’re table stakes for any plugin in production.
When in doubt, read the WordPress Plugin Security documentation and check your code against the OWASP Top 10. Your users are trusting you with their websites.