> wp-plugin-security
Security guidelines for WordPress plugin development: sanitization, validation, escaping, nonces, capabilities, SQL injection prevention, XSS protection, and CSRF mitigation. Based on official WordPress Developer Resources.
curl "https://skillshub.wtf/fernandotellado/ai-skills/wp-plugin-security?format=md"WordPress plugin security
When to use
Use this skill when:
- Developing new WordPress plugins or themes
- Reviewing existing code for security vulnerabilities
- Handling user input (forms, AJAX, REST API)
- Outputting dynamic content to the browser
- Interacting with the database
- Creating admin pages or settings
- Implementing AJAX or REST endpoints
- Processing file uploads
Core security principles
The security mantra
Sanitize early
Escape late
Always validate
Never trust user input
Key concepts
- Sanitization: Clean/filter input data as soon as it is received
- Validation: Verify data matches expected format/values (prefer over sanitization)
- Escaping: Secure output data before rendering to prevent XSS
- Nonces: Protect against CSRF attacks on forms and URLs
- Capabilities: Verify user has permission to perform actions
Sanitization
Sanitize input data immediately upon receipt. Use the most specific function available.
Sanitization functions
| Function | Use case |
|---|---|
sanitize_text_field() | Single-line text input |
sanitize_textarea_field() | Multi-line text input |
sanitize_email() | Email addresses |
sanitize_file_name() | File names |
sanitize_hex_color() | Color values with hash |
sanitize_hex_color_no_hash() | Color values without hash |
sanitize_html_class() | HTML class names |
sanitize_key() | Keys (lowercase alphanumeric, dashes, underscores) |
sanitize_meta() | Meta values |
sanitize_mime_type() | MIME types |
sanitize_option() | Option values |
sanitize_sql_orderby() | SQL ORDER BY clauses |
sanitize_title() | Titles/slugs |
sanitize_title_with_dashes() | URL-friendly titles |
sanitize_user() | Usernames |
sanitize_url() | URLs for storage |
wp_kses() | HTML with allowed tags |
wp_kses_post() | HTML allowed in posts |
Sanitization example
// Sanitize a text field from POST
$title = sanitize_text_field( $_POST['title'] ?? '' );
// Sanitize email
$email = sanitize_email( $_POST['email'] ?? '' );
// Sanitize URL for database storage
$url = sanitize_url( $_POST['website'] ?? '' );
// Sanitize textarea
$description = sanitize_textarea_field( $_POST['description'] ?? '' );
Important notes on sanitization
- Never use escape functions for sanitization - they serve different purposes
- When using
filter_var(), always specify a sanitizing filter (notFILTER_DEFAULT) - Process only the specific keys you need, not the entire
$_POST/$_GETarray
// CORRECT: Specify sanitizing filter
$post_id = filter_input( INPUT_GET, 'post_id', FILTER_SANITIZE_NUMBER_INT );
// WRONG: No filter or FILTER_DEFAULT does not sanitize
$post_id = filter_input( INPUT_GET, 'post_id' ); // Insecure!
Validation
Validation verifies data matches expected patterns. Prefer validation over sanitization when possible.
Validation philosophies
Safelist (recommended)
Accept only known, trusted values:
$allowed_values = array( 'draft', 'pending', 'publish' );
// Use strict comparison (third parameter = true)
if ( in_array( $status, $allowed_values, true ) ) {
// Valid
} else {
wp_die( 'Invalid status' );
}
Format detection
Test data format and reject if invalid:
// Check alphanumeric only
if ( ! ctype_alnum( $data ) ) {
wp_die( 'Invalid format' );
}
// Check against regex
if ( ! preg_match( '/^\d{5}(-\d{4})?$/', $zip_code ) ) {
wp_die( 'Invalid ZIP code format' );
}
Type checking
Always use strict comparison (===) to prevent type juggling attacks:
// CORRECT: Strict comparison
if ( 1 === $user_input ) {
// Exactly integer 1
}
// WRONG: Loose comparison - "1 malicious" == 1 evaluates to true
if ( 1 == $user_input ) {
// Vulnerable!
}
Validation functions
| Function | Purpose |
|---|---|
is_email() | Validate email format |
term_exists() | Check if taxonomy term exists |
username_exists() | Check if username exists |
validate_file() | Validate file path (not existence) |
is_array() | Check if value is array |
absint() | Return absolute integer |
in_array( $val, $arr, true ) | Check value in array (strict) |
Validation example
function ayudawp_is_valid_us_zip( string $zip ): bool {
if ( empty( $zip ) ) {
return false;
}
if ( strlen( trim( $zip ) ) > 10 ) {
return false;
}
if ( ! preg_match( '/^\d{5}(-?\d{4})?$/', $zip ) ) {
return false;
}
return true;
}
// Usage
if ( isset( $_POST['zip'] ) && ayudawp_is_valid_us_zip( $_POST['zip'] ) ) {
$zip = sanitize_text_field( $_POST['zip'] );
// Process valid ZIP
}
Escaping
Escape output data as late as possible, immediately when echoing.
Escaping functions
| Function | Use case |
|---|---|
esc_html() | Text inside HTML elements |
esc_attr() | Values inside HTML attributes |
esc_url() | URLs in href, src attributes |
esc_url_raw() | URLs for database storage (NOT escaping) |
esc_js() | Inline JavaScript values |
esc_textarea() | Content inside textarea |
esc_xml() | XML content |
wp_kses() | HTML with custom allowed tags |
wp_kses_post() | HTML allowed in post content |
wp_kses_data() | HTML allowed in comments |
Escaping examples
// Text inside HTML element
<h4><?php echo esc_html( $title ); ?></h4>
// URL in attribute
<a href="<?php echo esc_url( $link ); ?>">Link</a>
// Value in attribute
<input type="text" value="<?php echo esc_attr( $value ); ?>">
// Image source
<img src="<?php echo esc_url( $image_url ); ?>" alt="<?php echo esc_attr( $alt ); ?>">
// Inline JavaScript
<div onclick="doSomething('<?php echo esc_js( $param ); ?>')">
// Textarea content
<textarea><?php echo esc_textarea( $content ); ?></textarea>
// HTML content (preserves allowed HTML)
<div><?php echo wp_kses_post( $html_content ); ?></div>
Escape late pattern
Always escape at the point of output:
// WRONG: Escaping early
$url = esc_url( $url );
$text = esc_html( $text );
echo '<a href="' . $url . '">' . $text . '</a>';
// CORRECT: Escaping late
echo '<a href="' . esc_url( $url ) . '">' . esc_html( $text ) . '</a>';
Escaping with localization
Use combined escape + localization functions:
// Escape + translate
echo esc_html__( 'Hello World', 'text-domain' );
esc_html_e( 'Hello World', 'text-domain' );
// With context
echo esc_html_x( 'Post', 'noun', 'text-domain' );
// For attributes
echo esc_attr__( 'Submit', 'text-domain' );
esc_attr_e( 'Submit', 'text-domain' );
Available combined functions:
esc_html__(),esc_html_e(),esc_html_x()esc_attr__(),esc_attr_e(),esc_attr_x()
Important escaping notes
- Never use
__()or_e()without escaping - they do not escape output esc_url_raw()is NOT an escaping function - it's for sanitizing URLs for storage- Use
wp_kses_post()orwp_kses()for HTML output, NOTesc_html()which strips HTML - When escaping HTML attributes, escape the entire value, not parts
// CORRECT: Escape the whole attribute value
echo '<div id="' . esc_attr( $prefix . '-box-' . $id ) . '">';
// WRONG: Escaping parts separately
echo '<div id="' . esc_attr( $prefix ) . '-box-' . esc_attr( $id ) . '">';
Custom HTML escaping with wp_kses
$allowed_html = array(
'a' => array(
'href' => array(),
'title' => array(),
),
'br' => array(),
'em' => array(),
'strong' => array(),
);
echo wp_kses( $user_html, $allowed_html );
Nonces
Nonces protect against CSRF (Cross-Site Request Forgery) attacks.
Creating nonces
// In a URL
$url = wp_nonce_url( $base_url, 'delete-post_' . $post_id );
// In a form (echoes hidden fields)
wp_nonce_field( 'save-settings_' . $user_id, 'ayudawp_nonce' );
// Get nonce value only
$nonce = wp_create_nonce( 'my-action_' . $post_id );
Verifying nonces
// In admin screens (also checks referrer)
check_admin_referer( 'delete-post_' . $post_id, 'ayudawp_nonce' );
// In AJAX requests
check_ajax_referer( 'my-ajax-action', 'security' );
// Manual verification
if ( ! wp_verify_nonce(
sanitize_text_field( wp_unslash( $_POST['ayudawp_nonce'] ?? '' ) ),
'my-action_' . $post_id
) ) {
wp_die( 'Security check failed' );
}
Nonce best practices
- Make action strings specific:
'delete-post_' . $post_idnot just'delete' - Always sanitize nonce before verification:
// CORRECT: Sanitize nonce input
if ( ! isset( $_POST['_wpnonce'] ) ||
! wp_verify_nonce(
sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ),
'my_action'
)
) {
wp_die( 'Security check failed' );
}
- Nonces have limited lifetime (default 24 hours, configurable)
- Nonces alone are not sufficient - always combine with capability checks
- Nonces are user-specific and session-specific
Modifying nonce lifetime
add_filter( 'nonce_life', function() {
return 4 * HOUR_IN_SECONDS;
} );
User capabilities
Always verify user has permission before performing actions.
Checking capabilities
// Check current user capability
if ( ! current_user_can( 'edit_posts' ) ) {
wp_die( 'You do not have permission to do this.' );
}
// Check capability for specific post
if ( ! current_user_can( 'edit_post', $post_id ) ) {
wp_die( 'You cannot edit this post.' );
}
// Check if user is admin
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( 'Administrator access required.' );
}
Common capabilities
| Capability | Role level |
|---|---|
read | Subscriber+ |
edit_posts | Contributor+ |
publish_posts | Author+ |
edit_others_posts | Editor+ |
manage_options | Administrator |
edit_themes | Administrator |
activate_plugins | Administrator |
Complete security check example
function ayudawp_delete_item() {
// 1. Check nonce
if ( ! isset( $_POST['_wpnonce'] ) ||
! wp_verify_nonce(
sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ),
'delete_item_' . absint( $_POST['item_id'] ?? 0 )
)
) {
wp_die( 'Security check failed' );
}
// 2. Check capability
if ( ! current_user_can( 'delete_posts' ) ) {
wp_die( 'You do not have permission to delete items.' );
}
// 3. Validate and sanitize input
$item_id = absint( $_POST['item_id'] ?? 0 );
if ( ! $item_id ) {
wp_die( 'Invalid item ID' );
}
// 4. Perform action
// ... delete logic here
}
SQL injection prevention
Use $wpdb->prepare()
Always use prepared statements for database queries:
global $wpdb;
// Single value
$result = $wpdb->get_var(
$wpdb->prepare(
"SELECT post_title FROM {$wpdb->posts} WHERE ID = %d",
$post_id
)
);
// Multiple values
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$wpdb->posts} WHERE post_status = %s AND post_author = %d",
$status,
$author_id
)
);
Placeholders
| Placeholder | Type |
|---|---|
%d | Integer |
%f | Float |
%s | String |
%i | Identifier (table/column name, WP 6.2+) |
Arrays in queries
// Build placeholders for array
$ids = array( 1, 2, 3, 4, 5 );
$placeholders = implode( ', ', array_fill( 0, count( $ids ), '%d' ) );
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$wpdb->posts} WHERE ID IN ( $placeholders )",
$ids
)
);
Use WordPress functions when possible
Prefer WordPress API functions over direct SQL:
// PREFERRED: Use WordPress functions
update_post_meta( $post_id, 'my_key', $value );
get_option( 'my_option' );
WP_Query for post queries
// AVOID: Direct SQL unless necessary
$wpdb->query( "INSERT INTO..." );
Common vulnerabilities
XSS (Cross-Site Scripting)
Prevention: Escape all output
// Vulnerable
echo $user_input;
// Secure
echo esc_html( $user_input );
CSRF (Cross-Site Request Forgery)
Prevention: Use nonces + capability checks
// In form
wp_nonce_field( 'my_action', 'my_nonce' );
// On submission
check_admin_referer( 'my_action', 'my_nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( 'Unauthorized' );
}
SQL Injection
Prevention: Use prepared statements
// Vulnerable
$wpdb->query( "DELETE FROM table WHERE id = " . $_GET['id'] );
// Secure
$wpdb->query(
$wpdb->prepare( "DELETE FROM table WHERE id = %d", absint( $_GET['id'] ) )
);
File handling security
Use WordPress upload functions
// CORRECT: Use wp_handle_upload
$uploaded = wp_handle_upload( $_FILES['my_file'], array(
'test_form' => false
) );
// WRONG: Direct move_uploaded_file
move_uploaded_file( $_FILES['my_file']['tmp_name'], $destination );
Never allow unfiltered uploads
// NEVER DO THIS
define( 'ALLOW_UNFILTERED_UPLOADS', true );
// Instead, use upload_mimes filter for specific file types
add_filter( 'upload_mimes', function( $mimes ) {
$mimes['svg'] = 'image/svg+xml';
return $mimes;
} );
Validate file types
$allowed_types = array( 'image/jpeg', 'image/png', 'image/gif' );
$file_type = wp_check_filetype( $filename );
if ( ! in_array( $file_type['type'], $allowed_types, true ) ) {
wp_die( 'Invalid file type' );
}
Direct file access prevention
Add to all PHP files that could execute code:
<?php
// Prevent direct file access
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
AJAX security
Register AJAX handlers
// For logged-in users
add_action( 'wp_ajax_my_action', 'ayudawp_ajax_handler' );
// For non-logged-in users (if needed)
add_action( 'wp_ajax_nopriv_my_action', 'ayudawp_ajax_handler' );
function ayudawp_ajax_handler() {
// 1. Verify nonce
check_ajax_referer( 'my_ajax_nonce', 'security' );
// 2. Check capabilities
if ( ! current_user_can( 'edit_posts' ) ) {
wp_send_json_error( 'Unauthorized', 403 );
}
// 3. Sanitize input
$data = sanitize_text_field( $_POST['data'] ?? '' );
// 4. Process and respond
wp_send_json_success( array( 'result' => $data ) );
}
JavaScript side
// Localize script with nonce
wp_localize_script( 'my-script', 'myAjax', array(
'ajaxurl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'my_ajax_nonce' ),
) );
// AJAX call
jQuery.post( myAjax.ajaxurl, {
action: 'my_action',
security: myAjax.nonce,
data: 'my data'
}, function( response ) {
// Handle response
});
REST API security
register_rest_route( 'myplugin/v1', '/items', array(
'methods' => 'POST',
'callback' => 'ayudawp_create_item',
'permission_callback' => function() {
return current_user_can( 'edit_posts' );
},
'args' => array(
'title' => array(
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
'validate_callback' => function( $value ) {
return ! empty( $value );
},
),
),
) );
Code review checklist
Input handling
- All
$_POST,$_GET,$_REQUESTvalues are sanitized - All
$_FILESuploads usewp_handle_upload() - Database queries use
$wpdb->prepare() - Type casting used where appropriate (
absint(),(int), etc.)
Output handling
- All dynamic output is escaped
- Correct escape function used for context (html/attr/url/js)
- Escaping happens at output time (late escaping)
- Translation functions are escaped (
esc_html__()not__())
Authentication & authorization
- Nonces used on all forms and state-changing URLs
- Nonces verified before processing actions
- Capability checks performed before actions
- Both nonce AND capability checked (not just one)
General
- No
ALLOW_UNFILTERED_UPLOADS - Direct file access prevented with
ABSPATHcheck - No
error_reporting()in production code - No timezone changes with
date_default_timezone_set() - Uses WordPress HTTP API, not raw cURL
- Uses
wp_enqueue_*for scripts/styles
WPCS security sniffs
WordPress Coding Standards includes these security sniffs:
EscapeOutputSniff- Verifies output is escapedNonceVerificationSniff- Verifies nonce checksValidatedSanitizedInputSniff- Verifies input sanitizationSafeRedirectSniff- Verifies safe redirectsPluginMenuSlugSniff- Verifies menu slug safety
Run PHPCS with WordPress standards:
phpcs --standard=WordPress path/to/plugin
References
> related_skills --same-repo
> wp-plugin-performance
Performance guidelines for WordPress plugin development: database optimization, object caching, conditional asset loading, efficient hooks, HTTP requests, WP-Cron, AJAX/REST optimization, and common anti-patterns. Based on official WordPress Developer Resources and WP VIP documentation.
> wp-plugin-development
Architecture and development guidelines for WordPress plugins published on wordpress.org: file structure, plugin header, lifecycle hooks, Settings API, admin UI, custom post types, custom database tables, internationalization, plugin dependencies, and wordpress.org submission requirements. Based on the official WordPress Plugin Developer Handbook and Plugin Review Team guidelines.