Table of Contents
- Why Security Checklists Matter in 2026
- Input Validation and Sanitization
- Output Escaping Essentials
- CSRF Protection with Nonces
- Preventing SQL Injection
- Authentication and Capabilities
- File Operation Security
- REST API Security
- Data Privacy and GDPR
- Dependency and Supply Chain Security
- Automated Security Scanning
- Frequently Asked Questions
Why Security Checklists Matter in 2026
The WordPress plugin ecosystem processes transactions worth over $1.5 trillion annually. With that scale comes responsibility—and risk. According to the National Vulnerability Database, WordPress-related vulnerabilities have increased 340% since 2020, with plugin vulnerabilities accounting for roughly 60% of all disclosed issues.
For a comprehensive automated check against this entire list, upload your plugin to WP HealthKit — it covers all of these patterns and more. See how your plugin compares to others on the security leaderboard. Browse audited plugins in the WP HealthKit directory for real-world examples of secure code.
Regulatory pressure is intensifying. GDPR fines now reach €20 million or 4% of global revenue. California's CCPA and similar legislation in 25+ jurisdictions means every WordPress site handling personal data faces potential liability. Security isn't optional—it's a legal requirement.
The 2025-2026 threat landscape differs from previous years. Zero-day exploits targeting supply chains are becoming routine. Mass vulnerability scanning is more sophisticated. Attackers are increasingly targeting plugin dependencies rather than core WordPress code, because plugin maintainers often lack resources to patch quickly.
This checklist distills lessons from years of WordPress security research, OWASP guidance, and real-world vulnerability patterns. It's designed for plugin developers who want to ship secure code the first time.
Input Validation and Sanitization
Input validation is your first line of defense. The rule is absolute: never trust user input. This includes data from forms, URLs, cookies, $_FILES, and database queries from untrusted sources.
Validation means verifying that data matches what you expect. Sanitization means cleaning that data to remove or escape potentially harmful content. They serve different purposes and both are necessary.
Validating Form Input
When a user submits form data, validate immediately:
// Problem: No validation
$user_email = $_POST['email'];
update_user_meta(get_current_user_id(), 'contact_email', $user_email);
// Fix: Validate and sanitize
$user_email = isset($_POST['email']) ? sanitize_email($_POST['email']) : '';
if (!is_email($user_email)) {
wp_die('Invalid email address');
}
update_user_meta(get_current_user_id(), 'contact_email', $user_email);
WordPress provides validation functions for common data types: is_email(), is_numeric(), intval(), absint(). Use them before processing.
Sanitizing Text Input
Text input requires context-specific sanitization. The function you choose depends on where the data goes:
// Problem: Direct usage without sanitization
$plugin_name = $_POST['name'];
echo "Plugin: " . $plugin_name;
// Fix: Sanitize for HTML context
$plugin_name = isset($_POST['name']) ? sanitize_text_field($_POST['name']) : '';
echo "Plugin: " . esc_html($plugin_name);
sanitize_text_field() removes HTML tags and line breaks. Use it for data that shouldn't contain markup. For data that might contain limited HTML, use wp_kses_post() to allow specific safe tags.
Arrays and Complex Data
When processing arrays, validate each element:
// Problem: Processing unsanitized array
$settings = $_POST['settings'];
update_option('my_plugin_settings', $settings);
// Fix: Validate array structure and contents
$allowed_keys = ['color', 'width', 'height'];
$sanitized = [];
if (is_array($_POST['settings'])) {
foreach ($_POST['settings'] as $key => $value) {
if (in_array($key, $allowed_keys, true)) {
$sanitized[$key] = sanitize_text_field($value);
}
}
}
update_option('my_plugin_settings', $sanitized);
Use whitelisting—only accept known keys. This prevents attackers from injecting unexpected options.
Integer and Numeric Validation
When you expect a number, force it to be a number:
// Problem: Treating user input as number without conversion
$post_id = $_GET['post_id'];
$post = get_post($post_id);
// Fix: Cast to integer
$post_id = isset($_GET['post_id']) ? absint($_GET['post_id']) : 0;
if ($post_id === 0) {
wp_die('Invalid post ID');
}
$post = get_post($post_id);
absint() returns an absolute integer. intval() converts to integer but preserves negative numbers. Use the one that matches your requirements.
Output Escaping Essentials
Escaping is the second half of the security equation. You validate input when it arrives; you escape output when it leaves. Never output user data directly—always escape.
The escape function you use depends on where the data is displayed.
HTML Context: esc_html()
When displaying text inside HTML tags, use esc_html():
// Problem: Direct output
echo "<p>" . $user_comment . "</p>";
// Fix: Escape for HTML
echo "<p>" . esc_html($user_comment) . "</p>";
esc_html() converts special characters to HTML entities: < becomes <, & becomes &. This prevents HTML injection.
Attributes: esc_attr()
When outputting data in HTML attributes, esc_html() isn't sufficient. Use esc_attr():
// Problem: Unescaped attribute
echo "<input value='" . $user_input . "'>";
// Fix: Escape for attribute context
echo "<input value='" . esc_attr($user_input) . "'>";
An attacker could close the attribute with ' and inject JavaScript. esc_attr() handles this by escaping quotes and special characters.
URLs: esc_url()
URLs have their own syntax. Use esc_url() for href attributes and other URL contexts:
// Problem: Unescaped URL
echo '<a href="' . $redirect_url . '">Click</a>';
// Fix: Escape URL
echo '<a href="' . esc_url($redirect_url) . '">Click</a>';
esc_url() validates that a URL scheme is safe (http, https, ftp, mailto) and escapes characters. It prevents javascript: URLs and other attack vectors.
JavaScript Context: wp_json_encode()
If you're embedding data in JavaScript, use wp_json_encode():
// Problem: Direct embedding
?>
<script>
var config = <?php echo json_encode($user_config); ?>;
</script>
<?php
// Fix: Use WordPress function
?>
<script>
var config = <?php echo wp_json_encode($user_config); ?>;
</script>
<?php
wp_json_encode() properly escapes for JavaScript context. It's safer than bare json_encode().
Content with Allowed HTML: wp_kses()
Sometimes you need to allow limited HTML—for example, in user-submitted content. Use wp_kses() with a whitelist of allowed tags:
// Problem: Allowing all HTML
$user_bio = wp_kses_post($_POST['bio']);
// Fix: Whitelist specific tags
$allowed_html = [
'p' => [],
'br' => [],
'em' => [],
'strong' => [],
'a' => ['href' => [], 'title' => []],
];
$user_bio = wp_kses($_POST['bio'], $allowed_html);
wp_kses_post() is a convenience function that whitelists the tags WordPress posts commonly use. For stricter control, define your own whitelist.
Always Think About Context
The fundamental rule: know where your data is going. HTML body? Use esc_html(). HTML attribute? Use esc_attr(). URL? Use esc_url(). JavaScript? Use wp_json_encode(). When in doubt, use wp_kses() with a strict whitelist.
CSRF Protection with Nonces
Cross-Site Request Forgery (CSRF) attacks trick users into performing unintended actions. A user visits a malicious site while logged into your admin panel, and JavaScript on the malicious site makes a request to your plugin—without the user realizing.
Nonces (number used once) prevent this. A nonce is a token generated by your site, passed to the user, and verified when they submit. An attacker can't generate a valid nonce because they don't have access to your site's internal state.
Creating Nonces
Generate a nonce and include it in forms or URLs:
// In a form
?>
<form method="POST">
<?php wp_nonce_field('save_settings', 'settings_nonce'); ?>
<input type="text" name="color" value="#000000">
<button type="submit">Save</button>
</form>
<?php
// In a URL
$nonce = wp_create_nonce('delete_post_' . $post_id);
echo '<a href="' . esc_url(admin_url('admin-ajax.php?action=delete_post&post=' . $post_id . '&nonce=' . $nonce)) . '">Delete</a>';
wp_nonce_field() generates both the nonce and a hidden input field. For AJAX or URL nonces, use wp_create_nonce() directly.
Verifying Nonces
Always verify the nonce before processing the request:
// Problem: Processing form without nonce verification
if ($_POST['action'] === 'save_settings') {
update_option('my_settings', $_POST['data']);
}
// Fix: Verify nonce first
if ($_POST['action'] === 'save_settings') {
$nonce = isset($_POST['settings_nonce']) ? sanitize_text_field($_POST['settings_nonce']) : '';
if (!wp_verify_nonce($nonce, 'save_settings')) {
wp_die('Security check failed');
}
update_option('my_settings', $_POST['data']);
}
wp_verify_nonce() returns 1 if the nonce is valid, 2 if it's old (WordPress allows a 12-hour window), and false if it's invalid. In most cases, treat anything not 1 or 2 as an error.
AJAX and Nonces
For AJAX requests, pass the nonce in the JavaScript and verify on the server:
// Server-side: Generate and localize
$nonce = wp_create_nonce('my_ajax_action');
wp_localize_script('my-plugin-js', 'myPlugin', ['nonce' => $nonce]);
// Client-side: Send nonce with request
jQuery.post(ajaxurl, {
action: 'my_ajax_action',
nonce: myPlugin.nonce,
data: 'value'
});
// Server-side: Verify
add_action('wp_ajax_my_ajax_action', function() {
$nonce = isset($_POST['nonce']) ? sanitize_text_field($_POST['nonce']) : '';
if (!wp_verify_nonce($nonce, 'my_ajax_action')) {
wp_send_json_error('Nonce verification failed', 403);
}
// Process request
wp_send_json_success(['message' => 'Done']);
});
Never skip nonce verification, even for authenticated users. CSRF works against authenticated requests.
Preventing SQL Injection
SQL injection occurs when untrusted data is embedded directly in SQL queries. An attacker can break out of the query and inject malicious SQL.
WordPress's database functions protect you from SQL injection, but only if you use them correctly.
Prepared Statements
The safest approach is prepared statements with placeholders:
// Problem: Direct interpolation
global $wpdb;
$user_id = $_GET['user_id'];
$results = $wpdb->get_results("SELECT * FROM wp_users WHERE ID = $user_id");
// Fix: Use placeholders
global $wpdb;
$user_id = absint($_GET['user_id']);
$results = $wpdb->get_results($wpdb->prepare(
"SELECT * FROM wp_users WHERE ID = %d",
$user_id
));
$wpdb->prepare() takes a query with placeholders (%d for integers, %s for strings, %i for identifiers) and safely substitutes values. The database driver handles escaping.
String Placeholders
For string values, use %s:
$email = sanitize_email($_POST['email']);
$results = $wpdb->get_results($wpdb->prepare(
"SELECT * FROM wp_users WHERE user_email = %s",
$email
));
Even though you've sanitized, always use prepared statements. Sanitization is not a substitute.
Multiple Values
When you have multiple values, pass them as separate arguments:
$post_ids = [1, 2, 3];
$results = $wpdb->get_results($wpdb->prepare(
"SELECT * FROM wp_posts WHERE ID IN (%s)",
implode(',', array_map('intval', $post_ids))
));
For complex IN clauses, manually cast each value to the correct type (intval for integers).
Custom Table Queries
If you're querying custom tables, apply the same rules:
// Problem: Unescaped custom query
$results = $wpdb->get_results("SELECT * FROM custom_table WHERE status = '" . $_GET['status'] . "'");
// Fix: Use prepared statements
$status = sanitize_text_field($_GET['status']);
$results = $wpdb->get_results($wpdb->prepare(
"SELECT * FROM custom_table WHERE status = %s",
$status
));
There's no exception. Every query should use prepared statements.
Authentication and Capabilities
WordPress's capability system determines what users are allowed to do. Always verify that the current user has permission before performing sensitive actions.
Current User Checks
Before processing any request, verify the current user:
// Problem: No authentication check
if ($_POST['action'] === 'delete_data') {
delete_option('sensitive_data');
}
// Fix: Verify user is logged in
if ($_POST['action'] === 'delete_data') {
if (!is_user_logged_in()) {
wp_die('You must be logged in');
}
delete_option('sensitive_data');
}
is_user_logged_in() checks if a user is authenticated. For sensitive actions, this is a baseline requirement.
Capability Checks
Don't just check if a user is logged in—check what they're allowed to do:
// Problem: Only checking login status
if (is_user_logged_in()) {
delete_option('sensitive_data');
}
// Fix: Check specific capability
if (!current_user_can('manage_options')) {
wp_die('You do not have permission to do this');
}
delete_option('sensitive_data');
current_user_can() checks if the current user has a specific capability. 'manage_options' is for administrative actions. For post operations, use 'edit_posts', 'edit_post', 'delete_post', etc.
Custom Capabilities
If you define custom capabilities, check for them:
// When registering a role or adding a capability
$role = get_role('editor');
$role->add_cap('audit_security');
// When checking permission
if (!current_user_can('audit_security')) {
wp_die('You do not have the audit_security capability');
}
// Perform operation
perform_security_audit();
Always err on the side of caution. If a user shouldn't be able to do something, don't let them.
Admin Screens
For admin pages, use WordPress's built-in security functions:
add_action('admin_menu', function() {
add_menu_page(
'My Plugin Settings',
'My Plugin',
'manage_options', // Capability required to view this page
'my-plugin',
'my_plugin_page'
);
});
function my_plugin_page() {
if (!current_user_can('manage_options')) {
wp_die('You do not have permission to access this page');
}
// Page content
}
WordPress checks the capability automatically for registered admin pages, but it's good practice to double-check.
File Operation Security
File operations are a common attack vector. If an attacker can control the filename or path, they might write malicious files or overwrite important ones.
Uploading Files
WordPress provides the Media Library for safe file uploads. Use it:
// Problem: Direct file upload without validation
$uploaded = $_FILES['plugin_file'];
move_uploaded_file($uploaded['tmp_name'], '/uploads/plugins/' . $uploaded['name']);
// Fix: Use WordPress functions
if (!function_exists('wp_handle_upload')) {
require_once(ABSPATH . 'wp-admin/includes/file.php');
}
$uploaded = wp_handle_upload($_FILES['plugin_file'], [
'test_form' => true,
'mimes' => ['zip' => 'application/zip'],
]);
if (isset($uploaded['error'])) {
wp_die('Upload error: ' . $uploaded['error']);
}
$file_path = $uploaded['file'];
wp_handle_upload() validates file type (using WordPress's MIME database), checks for suspicious content, and stores the file safely.
Reading Files
When reading files, ensure the path is constrained:
// Problem: Reading arbitrary files
$file = $_GET['file'];
echo file_get_contents('/uploads/' . $file);
// Fix: Whitelist allowed files
$allowed_files = ['config.txt', 'data.json'];
$requested = isset($_GET['file']) ? basename($_GET['file']) : '';
if (!in_array($requested, $allowed_files, true)) {
wp_die('File not allowed');
}
$file_path = WP_CONTENT_DIR . '/uploads/' . $requested;
if (!file_exists($file_path)) {
wp_die('File not found');
}
echo file_get_contents($file_path);
Always use basename() to remove directory components. Never trust the full path from user input.
Writing Files
When writing files, validate paths and content:
// Problem: Writing to arbitrary paths
$filename = $_POST['filename'];
file_put_contents($filename, $content);
// Fix: Constrain to a specific directory
$filename = isset($_POST['filename']) ? sanitize_file_name($_POST['filename']) : '';
if (empty($filename) || strpos($filename, '/') !== false) {
wp_die('Invalid filename');
}
$file_path = WP_CONTENT_DIR . '/uploads/my-plugin/' . $filename;
// Create directory if needed
wp_mkdir_p(dirname($file_path));
file_put_contents($file_path, $content);
Use sanitize_file_name() to remove special characters. Constrain writes to a specific directory you control.
REST API Security
If your plugin exposes REST API endpoints, they're attack targets. The same security principles apply, but you need to be extra careful because API requests come from anywhere—including outside the WordPress admin.
Registering Secure Endpoints
When registering endpoints, specify the required permission callback:
// Problem: Endpoint with no permission check
register_rest_route('my-plugin/v1', '/audit', [
'methods' => 'POST',
'callback' => 'run_audit',
]);
// Fix: Require authentication and capability check
register_rest_route('my-plugin/v1', '/audit', [
'methods' => 'POST',
'callback' => 'run_audit',
'permission_callback' => function() {
return current_user_can('manage_options');
},
]);
function run_audit($request) {
// Process audit
return rest_ensure_response(['status' => 'success']);
}
Always set permission_callback. If you don't, WordPress defaults to allowing unauthenticated access.
Validating REST Parameters
REST endpoints receive parameters that must be validated:
register_rest_route('my-plugin/v1', '/scan', [
'methods' => 'POST',
'callback' => 'scan_plugins',
'permission_callback' => function() {
return current_user_can('manage_options');
},
'args' => [
'plugin_slug' => [
'type' => 'string',
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
],
'depth' => [
'type' => 'integer',
'default' => 1,
'validate_callback' => function($value) {
return $value >= 1 && $value <= 10;
},
],
],
]);
function scan_plugins($request) {
$plugin_slug = $request->get_param('plugin_slug');
$depth = $request->get_param('depth');
// Process request
return rest_ensure_response(['scanned' => true]);
}
The args array defines expected parameters. WordPress automatically validates and sanitizes them based on your specifications.
Escaping REST Responses
When returning data from REST endpoints, escape it:
function scan_plugins($request) {
$results = [];
foreach (get_plugins() as $plugin_file => $plugin_data) {
$results[] = [
'name' => isset($plugin_data['Name']) ? esc_html($plugin_data['Name']) : '',
'description' => isset($plugin_data['Description']) ? wp_kses_post($plugin_data['Description']) : '',
'version' => isset($plugin_data['Version']) ? esc_html($plugin_data['Version']) : '',
];
}
return rest_ensure_response($results);
}
Even though REST endpoints return JSON (which won't be directly rendered as HTML), escaping is still good practice in case the data is later displayed in a web context.
Data Privacy and GDPR
User data is increasingly regulated. GDPR, CCPA, and similar laws impose strict requirements on how you collect, store, and process personal information.
Consent Management
Before collecting personal data, get explicit consent:
function save_user_profile($request) {
if (!current_user_can('edit_user', get_current_user_id())) {
return rest_ensure_response(['error' => 'Unauthorized'], 403);
}
$user_id = get_current_user_id();
$email = $request->get_param('email');
$consent = $request->get_param('consent');
// Require explicit consent for data collection
if ($consent !== 'yes') {
return rest_ensure_response(['error' => 'Consent required'], 400);
}
update_user_meta($user_id, 'user_email_consent', 'yes');
update_user_meta($user_id, 'user_email', sanitize_email($email));
return rest_ensure_response(['success' => true]);
}
Don't assume consent. Make users explicitly opt-in to data collection.
Data Retention Policies
Document and enforce how long you store personal data:
// Implement a cleanup function
add_action('wp_scheduled_event_cleanup_data', function() {
global $wpdb;
// Delete user data older than 1 year
$cutoff = current_time('mysql', true) - YEAR_IN_SECONDS;
$wpdb->query($wpdb->prepare(
"DELETE FROM wp_postmeta WHERE meta_key = %s AND DATE_ADD(meta_value, INTERVAL 1 YEAR) < %s",
'user_data_timestamp',
$cutoff
));
});
// Schedule the cleanup
if (!wp_next_scheduled('wp_scheduled_event_cleanup_data')) {
wp_schedule_event(time(), 'weekly', 'wp_scheduled_event_cleanup_data');
}
Set a retention policy and enforce it. Don't keep data indefinitely.
User Data Export and Deletion
WordPress has hooks for the Core Privacy features. Use them:
add_filter('wp_privacy_personal_data_exporters', function($exporters) {
$exporters['my-plugin'] = [
'exporter_friendly_name' => 'My Plugin',
'callback' => 'my_plugin_exporter',
];
return $exporters;
});
function my_plugin_exporter($email_address, $page = 1) {
$user = get_user_by('email', $email_address);
if (!$user) {
return ['data' => [], 'done' => true];
}
$export_data = [];
// Collect user data
$user_data = get_user_meta($user->ID, 'my_plugin_data', true);
if ($user_data) {
$export_data[] = [
'group_id' => 'my_plugin',
'group_label' => 'My Plugin Data',
'item_id' => 'my_plugin_data',
'data' => [
['name' => 'Data', 'value' => esc_html($user_data)],
],
];
}
return ['data' => $export_data, 'done' => true];
}
add_filter('wp_privacy_personal_data_erasers', function($erasers) {
$erasers['my-plugin'] = [
'eraser_friendly_name' => 'My Plugin',
'callback' => 'my_plugin_eraser',
];
return $erasers;
});
function my_plugin_eraser($email_address, $page = 1) {
$user = get_user_by('email', $email_address);
if (!$user) {
return ['items_removed' => 0, 'items_retained' => 0, 'done' => true];
}
delete_user_meta($user->ID, 'my_plugin_data');
return ['items_removed' => 1, 'items_retained' => 0, 'done' => true];
}
Respect user privacy requests. Make it easy for users to export or delete their data.
Dependency and Supply Chain Security
Modern plugins often use third-party libraries through Composer. Each dependency is a potential security risk.
Managing Dependencies
Use Composer for dependency management and lock files to ensure consistent versions:
# Initialize Composer
composer init
# Add a dependency
composer require league/csv
# Lock versions
composer lock
Commit both composer.json and composer.lock to version control. The lock file ensures that every environment uses the same versions.
Checking for Vulnerabilities
WordPress security tools can scan dependencies:
# Using Composer audit
composer audit
# Using WP HealthKit (for comprehensive scanning)
# WP HealthKit checks plugin dependencies and flags known vulnerabilities
Run security audits regularly. Update dependencies when vulnerabilities are discovered.
Vendoring Dependencies
If you use Composer dependencies, vendor them into your plugin:
composer install --no-dev --prefer-dist
Include the vendor/ directory in your plugin distribution. This ensures users get the secure version automatically—they don't need to run Composer themselves.
Reviewing Dependencies
Before adding a dependency, evaluate it:
- Is the package actively maintained?
- Does it have known vulnerabilities?
- How many other packages depend on it? (More is usually better—more scrutiny)
- Is the source code available for review?
Don't blindly add packages. Evaluate the security implications.
Automated Security Scanning
Manual security review is necessary but insufficient. Automated tools catch mistakes humans miss.
PHPCS and Custom Rules
WordPress Coding Standards includes security checks:
# Install WordPress Coding Standards
composer require --dev squizlabs/php_codesniffer wp-coding-standards/wpcs
# Run PHPCS
phpcs --standard=WordPress-Security src/
PHPCS examines your code for common security patterns: unescaped output, SQL injection vulnerabilities, nonce verification issues. It won't catch everything, but it catches predictable mistakes.
PHPStan for Static Analysis
PHPStan performs deeper static analysis:
# Install PHPStan
composer require --dev phpstan/phpstan
# Run analysis
phpstan analyse src/
PHPStan tracks data flow through your code, identifying type mismatches and potential logic errors that could have security implications.
WP HealthKit for Plugin Audits
WP HealthKit provides comprehensive WordPress plugin security audits, scanning for:
- Vulnerable dependencies
- Unescaped output
- Missing nonce verification
- SQL injection risks
- Insecure file operations
- Authentication bypass vectors
- Hardcoded credentials
Upload your plugin to WP HealthKit for automated scanning. The system generates a detailed report highlighting issues and providing fixes.
Running a security audit is the fastest way to identify problems before they reach production.
Integrating Scanning into CI/CD
Add security scanning to your continuous integration pipeline:
# Example GitHub Actions workflow
name: Security Scan
on: [push, pull_request]
jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install dependencies
run: composer install
- name: Run PHPCS
run: phpcs --standard=WordPress-Security src/
- name: Run PHPStan
run: phpstan analyse src/
Fail the build if security issues are found. Don't merge code with known vulnerabilities.
For authoritative guidance on WordPress-specific security, refer to the WordPress Plugin Security Handbook and the OWASP Top 10 for broader web security context.
Frequently Asked Questions
What's the difference between sanitization and escaping?
Sanitization cleans data when it enters your system—it removes or modifies potentially harmful content. Escaping transforms data when it leaves your system—it formats it safely for its destination context. Sanitization happens once, when you receive data. Escaping happens every time you output data. Both are necessary. Sanitization on input alone is insufficient; an attacker could modify data in the database. Escaping on output alone is insufficient; an attacker could inject code during initial input and bypass sanitization.
Should I use nonces for every form submission?
Yes. Nonces protect against CSRF attacks, which can trick authenticated users into performing unintended actions. Even if a form seems harmless, an attacker could redirect traffic to a malicious site that submits the form on the user's behalf. It costs nothing to add a nonce and verify it. WordPress handles nonce generation and verification automatically through wp_nonce_field() and wp_verify_nonce(). There's no reason not to use them.
Can prepared statements prevent all SQL injection attacks?
Prepared statements prevent SQL injection by separating SQL code from data. The database driver treats everything in a placeholder as data, not code. This makes SQL injection impossible in the traditional sense. However, prepared statements don't protect against logical attacks—where the attacker supplies valid data that produces unintended results. For example, SELECT * FROM users WHERE id = 1 OR 1=1 is valid data; a prepared statement will execute it harmlessly, but it might bypass intended logic. Always validate data semantically, in addition to using prepared statements.
What capabilities should I check for admin pages?
It depends on what the page does. For general administrative settings, use manage_options. For pages that modify posts, use edit_post, delete_post, etc. For custom functionality, define custom capabilities and grant them to appropriate roles. When in doubt, require manage_options—that's the site administrator, who you can trust more than editors or authors. Document which capabilities each page requires so users can set up custom roles correctly.
How do I know if a third-party library is safe?
Check several factors: Is it actively maintained? Do the maintainers respond quickly to security reports? Does it have a public vulnerability history you can review? Does it follow security best practices in its documentation? Run automated tools like composer audit. Read the source code if you can. Look for security certifications or third-party audits. Use tools like Snyk or similar to track vulnerabilities. When evaluating a library, assume you'll need to maintain it yourself if the original maintainer abandons it. Choose established libraries with active communities.
Key Takeaways
WordPress plugin security rests on four foundations: validating input strictly, escaping output always, protecting against CSRF with nonces, and verifying user permissions. Every security issue in production can be traced to failure in one of these areas.
The checklist above covers the concrete technical controls. But security is also a mindset. Question every assumption about data. Assume users and attackers will misuse your code in ways you didn't anticipate. Prefer defensive patterns that fail safely over clever code that requires perfect conditions.
Automated tools catch obvious mistakes. Manual review catches subtle ones. The best approach combines both—automated scanning to find patterns, and human review to understand intent and logic.
WP HealthKit integrates automated security scanning into your development workflow, catching issues early and providing actionable fixes. It's one layer of defense. Combined with careful coding practices and regular reviews, it significantly reduces the risk of shipping vulnerable code.
Security isn't a feature you add at the end. It's a practice you adopt from the beginning. Start with this checklist, build it into your development process, and update it as threats evolve.