Skillshub 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.
git clone https://github.com/ComeOnOliver/skillshub
T=$(mktemp -d) && git clone --depth=1 https://github.com/ComeOnOliver/skillshub "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/fernandotellado/ai-skills/wp-plugin-security" ~/.claude/skills/comeonoliver-skillshub-wp-plugin-security && rm -rf "$T"
skills/fernandotellado/ai-skills/wp-plugin-security/SKILL.mdWordPress 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 |
|---|---|
| Single-line text input |
| Multi-line text input |
| Email addresses |
| File names |
| Color values with hash |
| Color values without hash |
| HTML class names |
| Keys (lowercase alphanumeric, dashes, underscores) |
| Meta values |
| MIME types |
| Option values |
| SQL ORDER BY clauses |
| Titles/slugs |
| URL-friendly titles |
| Usernames |
| URLs for storage |
| HTML with allowed tags |
| 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
, always specify a sanitizing filter (notfilter_var()
)FILTER_DEFAULT - Process only the specific keys you need, not the entire
/$_POST
array$_GET
// 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 |
|---|---|
| Validate email format |
| Check if taxonomy term exists |
| Check if username exists |
| Validate file path (not existence) |
| Check if value is array |
| Return absolute integer |
| 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 |
|---|---|
| Text inside HTML elements |
| Values inside HTML attributes |
| URLs in href, src attributes |
| URLs for database storage (NOT escaping) |
| Inline JavaScript values |
| Content inside textarea |
| XML content |
| HTML with custom allowed tags |
| HTML allowed in post content |
| 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__()
without escaping - they do not escape output_e()
is NOT an escaping function - it's for sanitizing URLs for storageesc_url_raw()- Use
orwp_kses_post()
for HTML output, NOTwp_kses()
which strips HTMLesc_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:
not just'delete-post_' . $post_id'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 |
|---|---|
| Subscriber+ |
| Contributor+ |
| Author+ |
| Editor+ |
| Administrator |
| Administrator |
| 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 |
|---|---|
| Integer |
| Float |
| String |
| 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
values are sanitized$_REQUEST - All
uploads use$_FILESwp_handle_upload() - Database queries use
$wpdb->prepare() - Type casting used where appropriate (
,absint()
, etc.)(int)
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 (
notesc_html__()
)__()
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
checkABSPATH - No
in production codeerror_reporting() - No timezone changes with
date_default_timezone_set() - Uses WordPress HTTP API, not raw cURL
- Uses
for scripts/styleswp_enqueue_*
WPCS security sniffs
WordPress Coding Standards includes these security sniffs:
- Verifies output is escapedEscapeOutputSniff
- Verifies nonce checksNonceVerificationSniff
- Verifies input sanitizationValidatedSanitizedInputSniff
- Verifies safe redirectsSafeRedirectSniff
- Verifies menu slug safetyPluginMenuSlugSniff
Run PHPCS with WordPress standards:
phpcs --standard=WordPress path/to/plugin