Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
A direct command skips the review prompt. Inspect the source before running it.
Security-first WordPress development with nonces, sanitization, validation, and escaping to prevent XSS, CSRF, and SQL injection vulnerabilities.
user-invocable
false
disable-model-invocation
true
version
1.1.0
updated
2026-06-15
progressive_disclosure
{"entry_point":{"summary":"Security-first WordPress development with nonces, sanitization, validation, and escaping for robust plugin/theme security","when_to_use":["Processing user input in forms or AJAX","Displaying untrusted content safely","Implementing capability checks and permissions"],"quick_start":["Sanitize on input with sanitize_* functions","Validate for logic with validation rules","Escape on output with esc_* functions"]},"references":["php-quality-antipatterns.md"]}
Security is not optional in WordPress development—it's fundamental. This skill teaches the three-layer security model that prevents XSS, CSRF, SQL injection, and other common web vulnerabilities through proper input sanitization, business logic validation, and output escaping.
The Golden Rule: "Sanitize on input, validate for logic, escape on output."
Why This Matters
Every year, thousands of WordPress sites are compromised due to security vulnerabilities in plugins and themes. Most of these attacks exploit one of three weaknesses:
XSS (Cross-Site Scripting): Malicious JavaScript injected through unsanitized output
CSRF (Cross-Site Request Forgery): Unauthorized actions performed on behalf of authenticated users
SQL Injection: Database manipulation through unsanitized database queries
This skill provides complete, production-ready patterns for preventing all three attack vectors.
The Three-Layer Security Model
WordPress security follows a defense-in-depth strategy with three distinct layers:
User Input → [1. SANITIZE] → [2. VALIDATE] → Process → [3. ESCAPE] → Output
Layer 1: Sanitization (Input Cleaning)
Purpose: Remove dangerous characters and normalize data format
When: Immediately upon receiving user input
Example:sanitize_text_field($_POST['username'])
Layer 2: Validation (Logic Checks)
Purpose: Ensure data meets business requirements
When: After sanitization, before processing
Example:if (!is_email($email)) { /* error */ }
Layer 3: Escaping (Output Protection)
Purpose: Prevent XSS by encoding special characters
When: Every time you output data to browser
Example:echo esc_html($user_input);
Critical Distinction:
Sanitization removes/transforms invalid data (changes the value)
Validation checks if data is acceptable (returns true/false)
Escaping makes data safe for display (context-specific encoding)
1. Nonces: CSRF Protection
What Are Nonces?
Nonces (Numbers Used Once) are cryptographic tokens that verify a request originated from your site, not a malicious external source. They prevent Cross-Site Request Forgery (CSRF) attacks.
How CSRF Attacks Work:
<!-- Attacker's malicious site: evil.com --><imgsrc="https://yoursite.com/wp-admin/admin.php?action=delete_user&id=1"><!-- If user is logged into yoursite.com, this executes! -->
How Nonces Prevent CSRF:
<!-- Legitimate request with nonce --><formaction="admin.php?action=delete_user&id=1"method="POST"><?php wp_nonce_field('delete_user_1', 'delete_nonce'); ?><button>Delete User</button></form><!-- Attacker cannot generate valid nonce (tied to user session) -->
Store nonces in cookies or URLs for long-term use (they expire)
Nonce Lifespan: WordPress nonces expire after 24 hours by default (12 hours in each direction due to time window).
2. Sanitization Functions Reference
Sanitization transforms user input into a safe format by removing or encoding dangerous characters. It's the first line of defense against malicious data.
Core Sanitization Functions
Function
Use Case
Example Input
Output
sanitize_text_field()
Single-line text (usernames, titles)
"Hello <script>alert('xss')</script>"
"Hello alert('xss')"
sanitize_email()
Email addresses
"user@example.com<script>"
"user@example.com"
sanitize_url() / esc_url_raw()
URLs (for storage)
"javascript:alert('xss')"
"" (blocked)
sanitize_key()
Array keys, meta keys
"my key!"
"my_key"
sanitize_file_name()
File uploads
"../../etc/passwd"
"..etcpasswd"
absint()
Positive integers
"-5", "42abc"
5, 42
intval()
Any integer
"-5", "42.7"
-5, 42
floatval()
Floating-point numbers
"3.14abc"
3.14
wp_kses_post()
HTML content (allows safe tags)
"<p>Safe</p><script>Bad</script>"
"<p>Safe</p>"
wp_kses()
HTML with custom allowed tags
See below
Custom filtering
sanitize_textarea_field()
Multi-line text
"Line 1\nLine 2<script>"
"Line 1\nLine 2"
sanitize_title()
Post slugs
"Hello World!"
"hello-world"
Detailed Examples
Text Sanitization
// Single-line text (removes HTML, line breaks, extra whitespace)$username = sanitize_text_field($_POST['username']);
// Input: " John <b>Doe</b>\n"// Output: "John Doe"// Multi-line text (preserves line breaks, removes HTML)$bio = sanitize_textarea_field($_POST['bio']);
// Input: "Line 1\nLine 2<script>alert('xss')</script>"// Output: "Line 1\nLine 2alert('xss')"// Email (validates format and removes invalid characters)$email = sanitize_email($_POST['email']);
// Input: "user@EXAMPLE.com <script>"// Output: "user@example.com"// URL (removes dangerous protocols)$website = esc_url_raw($_POST['website']);
// Input: "javascript:alert('xss')"// Output: "" (blocked protocol)// Input: "http://example.com"// Output: "http://example.com"
$age = absint($_POST['age']);
// Validate rangeif ($age < 18 || $age > 100) {
$errors[] = 'Age must be between 18 and 100';
}
// Validate positive numberif ($quantity <= 0) {
$errors[] = 'Quantity must be greater than zero';
}
String Length Validation
$username = sanitize_text_field($_POST['username']);
// Validate minimum lengthif (strlen($username) < 3) {
$errors[] = 'Username must be at least 3 characters';
}
// Validate maximum lengthif (strlen($username) > 20) {
$errors[] = 'Username cannot exceed 20 characters';
}
Required Field Validation
// Check if field exists and is not emptyif (empty($_POST['title']) || trim($_POST['title']) === '') {
$errors[] = 'Title is required';
}
// Alternative: isset() + non-empty checkif (!isset($_POST['terms']) || $_POST['terms'] !== 'accepted') {
$errors[] = 'You must accept the terms and conditions';
}
Pattern Matching (Regex)
$phone = sanitize_text_field($_POST['phone']);
// Validate phone format (US format: (555) 123-4567)if (!preg_match('/^\(\d{3}\) \d{3}-\d{4}$/', $phone)) {
$errors[] = 'Phone must be in format: (555) 123-4567';
}
// Validate alphanumeric only$product_code = sanitize_text_field($_POST['product_code']);
if (!preg_match('/^[a-zA-Z0-9]+$/', $product_code)) {
$errors[] = 'Product code must contain only letters and numbers';
}
Multi-Field Validation
functionvalidate_registration_form($data) {
$errors = [];
// Email validation$email = sanitize_email($data['email']);
if (!is_email($email)) {
$errors['email'] = 'Invalid email address';
} elseif (email_exists($email)) {
$errors['email'] = 'Email already registered';
}
// Username validation$username = sanitize_text_field($data['username']);
if (strlen($username) < 3) {
$errors['username'] = 'Username too short (minimum 3 characters)';
} elseif (username_exists($username)) {
$errors['username'] = 'Username already taken';
}
// Password validationif (strlen($data['password']) < 8) {
$errors['password'] = 'Password must be at least 8 characters'; // pragma: allowlist secret
}
// Password confirmationif ($data['password'] !== $data['password_confirm']) {
$errors['password_confirm'] = 'Passwords do not match'; // pragma: allowlist secret
}
// Age validation$age = absint($data['age']);
if ($age < 18) {
$errors['age'] = 'You must be 18 or older to register';
}
returnempty($errors) ? true : $errors;
}
// Usage$result = validate_registration_form($_POST);
if ($result === true) {
// Process registration
} else {
// Display errorsforeach ($resultas$field => $error) {
echo"<p class='error'>$error</p>";
}
}
Custom Validation Rules
// Validate URL is from allowed domainfunctionvalidate_allowed_domain($url) {
$allowed_domains = ['example.com', 'wordpress.org'];
$host = parse_url($url, PHP_URL_HOST);
returnin_array($host, $allowed_domains);
}
// Validate date format and rangefunctionvalidate_date($date_string) {
$date = DateTime::createFromFormat('Y-m-d', $date_string);
if (!$date) {
returnfalse; // Invalid format
}
// Check date is not in the past$now = newDateTime();
if ($date < $now) {
returnfalse;
}
returntrue;
}
// Validate credit card (Luhn algorithm)functionvalidate_credit_card($number) {
$number = preg_replace('/\D/', '', $number); // Remove non-digitsif (strlen($number) < 13 || strlen($number) > 19) {
returnfalse;
}
$sum = 0;
$double = false;
for ($i = strlen($number) - 1; $i >= 0; $i--) {
$digit = (int) $number[$i];
if ($double) {
$digit *= 2;
if ($digit > 9) {
$digit -= 9;
}
}
$sum += $digit;
$double = !$double;
}
return ($sum % 10) === 0;
}
4. Output Escaping Reference
Escaping prevents XSS (Cross-Site Scripting) by encoding special characters before output. This is the final security layer.
// Translate and escapeechoesc_html__('Welcome User', 'my-plugin');
// Translate with variable, then escape$message = sprintf(
__('Hello %s, you have %d new messages', 'my-plugin'),
esc_html($username),
absint($message_count)
);
echo$message;
// Escape translatable attributes
<input placeholder="<?php echo esc_attr__('Enter your name', 'my-plugin'); ?>">
// Allow HTML in translations (use wp_kses_post)$welcome_html = __('Welcome to <strong>My Plugin</strong>!', 'my-plugin');
echowp_kses_post($welcome_html);
Common Escaping Mistakes
❌ WRONG:
// Double-escaping (displays HTML entities to user)echoesc_html(esc_html($content)); // ⚠️ Displays &lt;script&gt;// Wrong function for contextecho'<a href="' . esc_html($url) . '">Link</a>'; // ⚠️ Use esc_url()// No escaping in JavaScriptecho"<script>var x = '$user_input';</script>"; // ⚠️ Use esc_js()// Escaping before storage (store raw, escape on output)update_option('setting', esc_html($value)); // ⚠️ Escape on output, not input
✅ CORRECT:
// Escape once, on outputechoesc_html($content);
// Use correct function for contextecho'<a href="' . esc_url($url) . '">' . esc_html($text) . '</a>';
// Escape JavaScript properlywp_localize_script('script', 'data', ['value' => $user_input]);
// Store raw, escape on outputupdate_option('setting', $value); // Store rawechoesc_html(get_option('setting')); // Escape on output
5. Capability Checks (Authorization)
Capability checks ensure users have permission to perform actions. Always combine with nonce verification.
Built-in Capabilities
Capability
Description
Default Roles
read
View content
All logged-in users
edit_posts
Create/edit own posts
Author, Editor, Admin
edit_published_posts
Edit published posts
Editor, Admin
delete_posts
Delete own posts
Author, Editor, Admin
manage_options
Manage site settings
Admin only
upload_files
Upload media
Author, Editor, Admin
edit_users
Edit user accounts
Admin only
delete_users
Delete users
Admin only
install_plugins
Install/activate plugins
Admin only
switch_themes
Change themes
Admin only
Capability Check Patterns
Basic Capability Check
// Check if user is logged inif (!is_user_logged_in()) {
wp_die('You must be logged in to access this page');
}
// Check if user has capabilityif (!current_user_can('manage_options')) {
wp_die('You do not have permission to manage settings');
}
// Check if user can edit specific post$post_id = absint($_GET['post_id']);
if (!current_user_can('edit_post', $post_id)) {
wp_die('You cannot edit this post');
}
Complete Security Example
add_action('admin_post_update_settings', 'handle_settings_update');
functionhandle_settings_update() {
// 1. Check if user is logged inif (!is_user_logged_in()) {
wp_die('You must be logged in');
}
// 2. Verify nonceif (!isset($_POST['settings_nonce']) ||
!wp_verify_nonce($_POST['settings_nonce'], 'update_settings')) {
wp_die('Security check failed');
}
// 3. Check user capabilityif (!current_user_can('manage_options')) {
wp_die('You do not have permission to update settings');
}
// 4. Sanitize input$api_key = sanitize_text_field($_POST['api_key']);
$enable_feature = isset($_POST['enable_feature']) ? 1 : 0;
// 5. Validate dataif (strlen($api_key) < 10) {
wp_die('API key must be at least 10 characters');
}
// 6. Update optionsupdate_option('my_plugin_api_key', $api_key);
update_option('my_plugin_enable_feature', $enable_feature);
// 7. Redirect with success messagewp_redirect(add_query_arg('message', 'updated', wp_get_referer()));
exit;
}
Post-Specific Capabilities
// Check if user can edit specific post$post_id = absint($_POST['post_id']);
if (!current_user_can('edit_post', $post_id)) {
wp_send_json_error(['message' => 'You cannot edit this post']);
}
// Check if user can delete specific postif (!current_user_can('delete_post', $post_id)) {
wp_send_json_error(['message' => 'You cannot delete this post']);
}
// Check if user can publish postsif (!current_user_can('publish_posts')) {
wp_send_json_error(['message' => 'You cannot publish posts']);
}
global$wpdb;
$status = sanitize_text_field($_POST['status']);
$min_price = floatval($_POST['min_price']);
// Multiple placeholders$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}products
WHERE status = %s AND price >= %f
ORDER BY created_at DESC
LIMIT %d",
$status,
$min_price,
10 // LIMIT value
)
);
Common SQL Injection Mistakes
❌ WRONG:
// String concatenation (vulnerable!)$sql = "SELECT * FROM table WHERE name = '" . $_POST['name'] . "'";
// Using esc_sql() (deprecated and insufficient)$sql = "SELECT * FROM table WHERE name = '" . esc_sql($_POST['name']) . "'";
// Not using placeholders$wpdb->query("DELETE FROM table WHERE id = $id"); // ⚠️ Vulnerable
✅ CORRECT:
// Always use $wpdb->prepare()$wpdb->get_results($wpdb->prepare(
"SELECT * FROM table WHERE name = %s",
$_POST['name']
));
// Use wpdb methods (insert, update, delete)$wpdb->insert('table', ['name' => $_POST['name']], ['%s']);
<!-- Attacker's site (evil.com) --><imgsrc="https://yoursite.com/wp-admin/admin.php?action=delete_all_posts"><!-- If admin is logged in, this executes without their knowledge! -->
All dynamic content escaped with esc_html(), esc_attr(), etc.
URLs escaped with esc_url()
JavaScript variables use wp_localize_script() or esc_js()
No raw echo $_POST or echo $_GET
Database Security
All queries use $wpdb->prepare()
No string concatenation in SQL
Use $wpdb->insert(), $wpdb->update(), $wpdb->delete()
Table names use $wpdb->prefix
Session Security
User authentication checked (is_user_logged_in())
User roles validated (current_user_can())
Sensitive operations require re-authentication
Session data never stored in GET parameters
Code Quality
No eval(), assert(), or create_function()
No extract() on user input
Error messages don't reveal system information
Debug mode disabled in production (WP_DEBUG = false)
No phpinfo() / var_dump() / print_r() reachable in production (server-info disclosure)
No empty catch blocks on security-relevant operations — fail closed and log the cause
Every switch over user-controlled or access-control input has a fail-closed default
Code-quality defects with a security dimension:phpinfo() left in production
leaks server internals; empty catch blocks silently swallow failed signature or
capability checks (failing open); a switch with no default lets unexpected input
fall through unhandled (CWE-478). See
PHP Quality Anti-Patterns for
compliant/non-compliant examples. Patterns derived from CAST Highlight code quality
indicators (https://doc.casthighlight.com/).
10. Testing Your Security Implementation
Manual Testing Checklist
1. Test Nonce Expiration:
# Generate form with nonce, wait 25 hours, submit# Expected: "Security check failed" error
2. Test CSRF Protection:
<!-- Create external form pointing to your site --><formaction="https://yoursite.com/wp-admin/admin-post.php"method="POST"><inputname="action"value="your_action"><button>Submit</button></form><!-- Expected: Nonce verification fails -->
Remember: Security is not a feature—it's a requirement. Every line of code that handles user input or displays data must follow these principles. When in doubt, sanitize, validate, and escape.