| name | wordpress-security-hardening |
| description | Use when implementing WordPress security best practices, sanitizing user input, escaping output, verifying nonces, or reviewing code for security vulnerabilities. Keywords: security, sanitize, escape, nonce, XSS, SQL injection, CSRF, WordPress security, input validation, output escaping |
WordPress Security Hardening
Overview
WordPress security requires defense-in-depth: sanitize ALL input, escape ALL output, verify nonces for state changes, check capabilities for privileged operations, and use prepared statements for database queries.
Core Principle: NEVER trust user input. ALWAYS sanitize input, escape output, verify nonces, check capabilities, and prepare SQL queries.
When to Use
Use this skill when:
- Processing user input from forms, AJAX, REST API
- Displaying dynamic content to users
- Performing state-changing operations (create, update, delete)
- Executing privileged operations
- Writing custom database queries
- Handling file uploads
- Implementing authentication/authorization
Symptoms that trigger this skill:
- "security review"
- "sanitize input"
- "escape output"
- "nonce verification"
- "prevent XSS"
- "SQL injection"
- "user capabilities"
- "CSRF protection"
When NOT to use:
- Reading static configuration (no user input)
- Outputting hardcoded strings (no dynamic content)
- WordPress core functions handle security automatically
Security Decision Flowchart
┌─────────────────────┐
│ Receiving user │
│ input? │
└──────┬──────────────┘
│ YES
▼
┌─────────────────────┐
│ Sanitize │
│ immediately │
└──────┬──────────────┘
│
▼
┌─────────────────────┐
│ Outputting data? │
└──────┬──────────────┘
│ YES
▼
┌─────────────────────┐
│ Escape for context │
│ (HTML/URL/attr/JS) │
└──────┬──────────────┘
│
▼
┌─────────────────────┐
│ State-changing │
│ operation? │
└──────┬──────────────┘
│ YES
▼
┌─────────────────────┐
│ Verify nonce │
└──────┬──────────────┘
│
▼
┌─────────────────────┐
│ Privileged │
│ operation? │
└──────┬──────────────┘
│ YES
▼
┌─────────────────────┐
│ Check capabilities │
└──────┬──────────────┘
│
▼
┌─────────────────────┐
│ Custom SQL query? │
└──────┬──────────────┘
│ YES
▼
┌─────────────────────┐
│ Use $wpdb->prepare()│
└──────┬──────────────┘
│
▼
┌─────────────────────┐
│ Safe to proceed │
└─────────────────────┘
Quick Reference
Input Sanitization
| Input Type | Function | Example |
|---|
| Text field | sanitize_text_field() | Form inputs, meta fields |
| Textarea | sanitize_textarea_field() | Multi-line text |
| Email | sanitize_email() | Email addresses |
| URL | esc_url_raw() | URLs for database storage |
| Filename | sanitize_file_name() | File uploads |
| HTML | wp_kses_post() | Rich text content |
| HTML (limited) | wp_kses() | Custom allowed tags |
| Key/slug | sanitize_key() | Option names, meta keys |
| Title | sanitize_title() | Post titles, slugs |
| SQL LIKE | $wpdb->esc_like() | LIKE query parameters |
Output Escaping
| Context | Function | Example |
|---|
| HTML content | esc_html() | Display text |
| HTML attribute | esc_attr() | Input values, data attributes |
| URL | esc_url() | Link href, src attributes |
| JavaScript | esc_js() | Inline JavaScript strings |
| Textarea | esc_textarea() | Textarea value attribute |
| Translation | esc_html__() | Translated strings for display |
| Translation + printf | esc_html_e() | Echo translated string |
Nonce Functions
| Function | Purpose | When to Use |
|---|
wp_nonce_field() | Add nonce to form | Form submissions |
wp_create_nonce() | Generate nonce | AJAX, custom implementations |
wp_verify_nonce() | Verify nonce | Process form data, AJAX |
check_admin_referer() | Verify nonce + die | Admin form processing |
check_ajax_referer() | Verify AJAX nonce + die | AJAX handlers |
wp_nonce_url() | Add nonce to URL | Action links (delete, activate) |
Capability Checks
| Capability | Purpose | When to Check |
|---|
manage_options | Admin settings | Saving plugin/theme options |
edit_posts | Edit posts | Post creation/editing |
publish_posts | Publish posts | Publishing content |
edit_others_posts | Edit other users' posts | Multi-author workflows |
delete_posts | Delete posts | Post deletion |
upload_files | Upload files | Media uploads |
edit_theme_options | Theme customization | Theme settings |
install_plugins | Install plugins | Plugin installation |
Implementation Patterns
Pattern 1: Form Processing with Security
<?php
function mytheme_process_contact_form() {
if ( ! isset( $_POST['contact_nonce'] ) || ! wp_verify_nonce( $_POST['contact_nonce'], 'contact_form_action' ) ) {
wp_die( __( 'Security check failed', 'mytheme' ) );
}
if ( ! current_user_can( 'edit_posts' ) ) {
wp_die( __( 'Insufficient permissions', 'mytheme' ) );
}
$name = isset( $_POST['name'] ) ? sanitize_text_field( $_POST['name'] ) : '';
$email = isset( $_POST['email'] ) ? sanitize_email( $_POST['email'] ) : '';
$message = isset( $_POST['message'] ) ? sanitize_textarea_field( $_POST['message'] ) : '';
if ( empty( $name ) || empty( $email ) || empty( $message ) ) {
wp_die( __( 'All fields are required', 'mytheme' ) );
}
if ( ! is_email( $email ) ) {
wp_die( __( 'Invalid email address', 'mytheme' ) );
}
$sent = wp_mail( 'admin@example.com', 'Contact Form', $message, array(
'From: ' . $name . ' <' . $email . '>'
) );
if ( $sent ) {
wp_redirect( add_query_arg( 'contact', 'success', home_url( '/contact' ) ) );
exit;
} else {
wp_die( __( 'Failed to send message', 'mytheme' ) );
}
}
add_action( 'admin_post_contact_form', 'mytheme_process_contact_form' );
add_action( 'admin_post_nopriv_contact_form', 'mytheme_process_contact_form' );
?>
Form HTML:
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<?php wp_nonce_field( 'contact_form_action', 'contact_nonce' ); ?>
<input type="hidden" name="action" value="contact_form">
<label for="name"><?php esc_html_e( 'Name', 'mytheme' ); ?></label>
<input type="text" id="name" name="name" value="<?php echo isset( $_POST['name'] ) ? esc_attr( $_POST['name'] ) : ''; ?>" required>
<label for="email"><?php esc_html_e( 'Email', 'mytheme' ); ?></label>
<input type="email" id="email" name="email" value="<?php echo isset( $_POST['email'] ) ? esc_attr( $_POST['email'] ) : ''; ?>" required>
<label for="message"><?php esc_html_e( 'Message', 'mytheme' ); ?></label>
<textarea id="message" name="message" required><?php echo isset( $_POST['message'] ) ? esc_textarea( $_POST['message'] ) : ''; ?></textarea>
<button type="submit"><?php esc_html_e( 'Send', 'mytheme' ); ?></button>
</form>
Pattern 2: AJAX Request with Security
<?php
function mytheme_save_preferences() {
check_ajax_referer( 'save_preferences_nonce', 'security' );
if ( ! current_user_can( 'edit_posts' ) ) {
wp_send_json_error( array( 'message' => __( 'Insufficient permissions', 'mytheme' ) ) );
}
$preference = isset( $_POST['preference'] ) ? sanitize_text_field( $_POST['preference'] ) : '';
$value = isset( $_POST['value'] ) ? sanitize_text_field( $_POST['value'] ) : '';
if ( empty( $preference ) ) {
wp_send_json_error( array( 'message' => __( 'Invalid preference', 'mytheme' ) ) );
}
$updated = update_user_meta( get_current_user_id(), 'mytheme_' . $preference, $value );
if ( $updated !== false ) {
wp_send_json_success( array( 'message' => __( 'Preferences saved', 'mytheme' ) ) );
} else {
wp_send_json_error( array( 'message' => __( 'Failed to save preferences', 'mytheme' ) ) );
}
}
add_action( 'wp_ajax_save_preferences', 'mytheme_save_preferences' );
?>
JavaScript (AJAX call):
jQuery(document).ready(function($) {
$('#save-preferences').on('click', function(e) {
e.preventDefault();
$.ajax({
url: ajaxurl,
type: 'POST',
data: {
action: 'save_preferences',
security: '<?php echo wp_create_nonce( "save_preferences_nonce" ); ?>',
preference: $('#preference').val(),
value: $('#value').val()
},
success: function(response) {
if (response.success) {
alert(response.data.message);
} else {
alert(response.data.message);
}
}
});
});
});
Pattern 3: Custom Database Query (Secure)
<?php
function mytheme_get_posts_by_meta( $meta_key, $meta_value ) {
global $wpdb;
$meta_key = sanitize_key( $meta_key );
$meta_value = sanitize_text_field( $meta_value );
$query = $wpdb->prepare(
"SELECT p.* FROM {$wpdb->posts} p
INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
WHERE pm.meta_key = %s
AND pm.meta_value = %s
AND p.post_status = 'publish'
AND p.post_type = 'post'
ORDER BY p.post_date DESC
LIMIT 10",
$meta_key,
$meta_value
);
$results = $wpdb->get_results( $query );
return $results;
}
?>
WRONG (SQL injection vulnerability):
<?php
function mytheme_get_posts_by_meta_WRONG( $meta_key, $meta_value ) {
global $wpdb;
$query = "SELECT * FROM {$wpdb->posts} p
INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
WHERE pm.meta_key = '$meta_key'
AND pm.meta_value = '$meta_value'";
$results = $wpdb->get_results( $query );
return $results;
}
?>
Pattern 4: File Upload Security
<?php
function mytheme_handle_file_upload() {
check_admin_referer( 'file_upload_nonce', 'security' );
if ( ! current_user_can( 'upload_files' ) ) {
wp_die( __( 'Insufficient permissions to upload files', 'mytheme' ) );
}
if ( ! isset( $_FILES['file'] ) ) {
wp_die( __( 'No file uploaded', 'mytheme' ) );
}
$allowed_types = array( 'image/jpeg', 'image/png', 'image/gif' );
$file_type = $_FILES['file']['type'];
if ( ! in_array( $file_type, $allowed_types, true ) ) {
wp_die( __( 'Invalid file type. Only JPG, PNG, and GIF allowed.', 'mytheme' ) );
}
$max_size = 2 * 1024 * 1024;
if ( $_FILES['file']['size'] > $max_size ) {
wp_die( __( 'File too large. Maximum size: 2MB', 'mytheme' ) );
}
require_once( ABSPATH . 'wp-admin/includes/file.php' );
require_once( ABSPATH . 'wp-admin/includes/media.php' );
require_once( ABSPATH . 'wp-admin/includes/image.php' );
$attachment_id = media_handle_upload( 'file', 0 );
if ( is_wp_error( $attachment_id ) ) {
wp_die( $attachment_id->get_error_message() );
}
wp_redirect( add_query_arg( 'upload', 'success', admin_url( 'admin.php?page=mytheme' ) ) );
exit;
}
add_action( 'admin_post_mytheme_upload', 'mytheme_handle_file_upload' );
?>
Pattern 5: REST API Endpoint with Security
<?php
function mytheme_register_rest_routes() {
register_rest_route( 'mytheme/v1', '/settings', array(
'methods' => 'POST',
'callback' => 'mytheme_update_settings',
'permission_callback' => 'mytheme_settings_permission_check',
'args' => array(
'option_name' => array(
'required' => true,
'validate_callback' => function( $param, $request, $key ) {
return is_string( $param ) && ! empty( $param );
},
'sanitize_callback' => 'sanitize_key',
),
'option_value' => array(
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
),
),
) );
}
add_action( 'rest_api_init', 'mytheme_register_rest_routes' );
function mytheme_settings_permission_check() {
return current_user_can( 'manage_options' );
}
function mytheme_update_settings( $request ) {
$option_name = $request['option_name'];
$option_value = $request['option_value'];
if ( ! in_array( $option_name, array( 'mytheme_setting_1', 'mytheme_setting_2' ), true ) ) {
return new WP_Error( 'invalid_option', __( 'Invalid option name', 'mytheme' ), array( 'status' => 400 ) );
}
$updated = update_option( $option_name, $option_value );
if ( $updated ) {
return new WP_REST_Response( array(
'success' => true,
'message' => __( 'Settings updated', 'mytheme' ),
), 200 );
} else {
return new WP_Error( 'update_failed', __( 'Failed to update settings', 'mytheme' ), array( 'status' => 500 ) );
}
}
?>
Common Mistakes
1. Trusting User Input
WRONG:
<?php
$name = $_POST['name'];
echo $name;
?>
WHY THIS FAILS:
- User can inject malicious JavaScript
- XSS (Cross-Site Scripting) vulnerability
- Can steal session cookies, redirect users, deface site
CORRECT:
<?php
$name = isset( $_POST['name'] ) ? sanitize_text_field( $_POST['name'] ) : '';
echo esc_html( $name );
?>
2. Double Escaping or Double Sanitizing
WRONG:
<?php
$email = sanitize_email( sanitize_text_field( $_POST['email'] ) );
$url = esc_url( esc_url( $link ) );
?>
WHY THIS FAILS:
- Corrupts data (especially special characters)
- Unnecessary performance overhead
- Can break functionality
CORRECT:
<?php
$email = sanitize_email( $_POST['email'] );
$url = esc_url( $link );
?>
3. Using Wrong Escape Function for Context
WRONG:
<a href="<?php echo esc_html( $url ); ?>">Link</a> <!-- Wrong: esc_html for URL -->
<div data-value="<?php echo esc_url( $value ); ?>">Content</div> <!-- Wrong: esc_url for attribute -->
WHY THIS FAILS:
- Security holes (URL context needs esc_url)
- Data corruption (attribute context needs esc_attr)
- JavaScript breaks (JS context needs esc_js)
CORRECT:
<a href="<?php echo esc_url( $url ); ?>">Link</a>
<div data-value="<?php echo esc_attr( $value ); ?>">Content</div>
4. Missing Nonce Verification
WRONG:
<?php
if ( isset( $_POST['submit'] ) ) {
update_option( 'mytheme_setting', $_POST['value'] );
}
?>
WHY THIS FAILS:
- CSRF (Cross-Site Request Forgery) vulnerability
- Attacker can trick users into performing actions
- No verification of form origin
CORRECT:
<?php
if ( isset( $_POST['submit'] ) && isset( $_POST['_wpnonce'] ) ) {
if ( wp_verify_nonce( $_POST['_wpnonce'], 'mytheme_settings' ) ) {
update_option( 'mytheme_setting', sanitize_text_field( $_POST['value'] ) );
} else {
wp_die( __( 'Security check failed', 'mytheme' ) );
}
}
?>
5. Not Checking User Capabilities
WRONG:
<?php
if ( is_user_logged_in() ) {
wp_delete_post( $_POST['post_id'] );
}
?>
WHY THIS FAILS:
- Privilege escalation vulnerability
- Unauthorized users can perform admin actions
- Data loss, security breach
CORRECT:
<?php
if ( current_user_can( 'delete_posts' ) && isset( $_POST['_wpnonce'] ) ) {
if ( wp_verify_nonce( $_POST['_wpnonce'], 'delete_post_' . $_POST['post_id'] ) ) {
wp_delete_post( sanitize_text_field( $_POST['post_id'] ) );
}
}
?>
6. SQL Injection in Custom Queries
WRONG:
<?php
global $wpdb;
$id = $_GET['id'];
$query = "SELECT * FROM {$wpdb->posts} WHERE ID = $id";
$results = $wpdb->get_results( $query );
?>
WHY THIS FAILS:
- SQL injection vulnerability
- Attacker can read/modify database
- Can dump entire database, escalate privileges
CORRECT:
<?php
global $wpdb;
$id = isset( $_GET['id'] ) ? absint( $_GET['id'] ) : 0;
$query = $wpdb->prepare( "SELECT * FROM {$wpdb->posts} WHERE ID = %d", $id );
$results = $wpdb->get_results( $query );
?>
7. Using stripslashes() or strip_tags()
WRONG:
<?php
$value = stripslashes( $_POST['value'] );
$html = strip_tags( $_POST['html'] );
?>
WHY THIS FAILS:
- stripslashes() is outdated (magic quotes removed in PHP 5.4)
- strip_tags() removes ALL HTML (not selective)
- Neither function sanitizes properly
CORRECT:
<?php
$value = sanitize_text_field( $_POST['value'] );
$html = wp_kses_post( $_POST['html'] );
?>
Red Flags - Rationalization Detection
| Rationalization | Reality | Correct Action |
|---|
| "It's an admin page, users are trusted" | Admin users can be compromised | Always sanitize and escape |
| "I'll validate on the frontend" | Frontend can be bypassed | Always validate server-side |
| "This is just for testing" | Test code becomes production code | Build security in from start |
| "stripslashes() is simpler" | It's outdated and insecure | Use sanitize_text_field() |
| "Nonces slow down the site" | Minimal performance impact | Always use nonces |
| "is_user_logged_in() is enough" | Not a capability check | Use current_user_can() |
| "I'll add security later" | Security must be built in | Sanitize/escape from day one |
No Exceptions
NEVER skip these security requirements:
- ✅ Sanitize ALL user input - $_POST, $_GET, $_REQUEST, $_FILES, $_COOKIE
- ✅ Escape ALL output - HTML, attributes, URLs, JavaScript
- ✅ Verify nonces for state changes - Forms, AJAX, action links
- ✅ Check capabilities for privileged operations - Admin functions, data modification
- ✅ Use $wpdb->prepare() for custom queries - ALWAYS prepare SQL
- ✅ Validate file uploads - Type, size, use WordPress upload handlers
- ✅ Use WordPress functions - Don't reinvent security functions
Time pressure is not a valid reason to skip security.
"Only admins use this" is not a valid reason to skip security.
"I'll fix it later" is not acceptable for security.
Integration with This Template
This skill works with:
- fse-block-theme-development skill - Security during theme development
- block-pattern-creation skill - Sanitizing pattern output
- wp-cli-workflows skill - Secure automation scripts
- All agents - Security applies to all WordPress development
Complements:
- security-scan.sh script - Automated vulnerability scanning
- check-coding-standards.sh script - WPCS security rules
- test-writer-fixer agent - Writing security tests
Security Testing Checklist
Before deploying code:
Resources
Skill Version: 1.0.0
Last Updated: 2026-01-18
Tested Against: WordPress 6.7+, PHP 7.4+
Testing Methodology: RED-GREEN-REFACTOR (TDD for documentation)