Back to Articles
20 min read

WordPress Security Engineering: Validation, Sanitization & Access Control

Security is the primary responsibility of the backend engineer. This guide establishes the protocols for defensive programming in WordPress: enforcing trust boundaries with Nonces, managing user Capabilities, and rigorously applying the Input/Output pipeline (Validate, Sanitize, Escape) to prevent common vulnerabilities.

Nonces

┌─────────────────────────────────────────────────────────────────┐ │ NONCE FLOW IN WORDPRESS │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ [1] CREATE [2] SUBMIT [3] VERIFY │ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ │ Form/ │ ───► │ Server │ ───► │ Check │ │ │ │ AJAX │ │ Request │ │ Token │ │ │ └─────────┘ └─────────┘ └─────────┘ │ │ │ │ │ │ ▼ ▼ │ │ wp_nonce_field() wp_verify_nonce() → 1, 2, false │ │ wp_create_nonce() check_admin_referer() │ │ check_ajax_referer() │ │ │ │ Lifetime: 24 hours (two 12-hour "ticks") │ └─────────────────────────────────────────────────────────────────┘

wp_nonce_field()

Generates hidden form fields containing a nonce token and optionally a referrer field for CSRF protection in HTML forms; it outputs directly to the page and is essential for securing any form submission in WordPress admin or frontend.

<form method="post"> <?php wp_nonce_field( 'delete_post_action', 'delete_post_nonce' ); ?> <input type="submit" value="Delete Post"> </form> <!-- Outputs: <input type="hidden" name="delete_post_nonce" value="a3f5b9c2d1"> -->

wp_create_nonce()

Returns a nonce token as a string (instead of outputting HTML), commonly used for AJAX requests or when you need to append the nonce to a URL manually; the token is tied to the current user session and the action name you specify.

$nonce = wp_create_nonce( 'my_ajax_action' ); $ajax_url = admin_url( 'admin-ajax.php?action=my_action&_nonce=' . $nonce ); // In JavaScript: // jQuery.post(ajaxurl, { action: 'my_action', _nonce: '<?php echo $nonce; ?>' });

wp_verify_nonce()

Validates a nonce token against an action string and returns 1 if valid within first 12 hours, 2 if valid within 12-24 hours, or false if invalid/expired; always use this before processing any user-submitted data.

if ( ! wp_verify_nonce( $_POST['delete_post_nonce'], 'delete_post_action' ) ) { wp_die( 'Security check failed!' ); } // Safe to proceed with action

check_admin_referer()

A convenience wrapper that verifies the nonce AND checks the HTTP referer for admin forms; it will call wp_die() automatically if verification fails, making it ideal for quick security checks in admin handlers.

add_action( 'admin_post_save_settings', 'handle_save_settings' ); function handle_save_settings() { check_admin_referer( 'save_settings_action', 'settings_nonce' ); // If we reach here, nonce is valid update_option( 'my_setting', sanitize_text_field( $_POST['my_setting'] ) ); }

check_ajax_referer()

Similar to check_admin_referer() but specifically designed for AJAX requests; by default it looks for the nonce in $_REQUEST['_ajax_nonce'] or $_REQUEST['_wpnonce'] and dies if invalid unless you pass false as the third parameter.

add_action( 'wp_ajax_delete_item', 'ajax_delete_item' ); function ajax_delete_item() { check_ajax_referer( 'delete_item_nonce', 'security' ); $item_id = absint( $_POST['item_id'] ); // Process deletion... wp_send_json_success( 'Item deleted' ); }

Nonce Lifetime

Nonces are valid for 24 hours by default, divided into two 12-hour "ticks" (which is why wp_verify_nonce() returns 1 or 2); you can modify the lifetime using the nonce_life filter, but shortening it too much may cause usability issues with cached pages.

// Change nonce lifetime to 4 hours add_filter( 'nonce_life', function() { return 4 * HOUR_IN_SECONDS; }); // Verification return values: // 1 = Valid, created 0-12 hours ago (tick 1) // 2 = Valid, created 12-24 hours ago (tick 2) // false = Invalid or expired

Capability Checks

┌─────────────────────────────────────────────────────────────────┐ │ WORDPRESS ROLE & CAPABILITY HIERARCHY │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ Super Admin ─► Administrator ─► Editor ─► Author ─► Contrib. │ │ │ │ │ │ │ │ │ ▼ ▼ ▼ ▼ ▼ │ │ [manage_sites] [manage_options] [publish [publish [edit │ │ [manage_network] [edit_users] _pages] _posts] _posts]│ │ [install_plugins] │ │ │ │ ┌─────────────────────────────────────────────────────────┐ │ │ │ current_user_can('capability') → true/false │ │ │ │ user_can($user_id, 'capability') → true/false │ │ │ └─────────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────┘

current_user_can()

The primary function for checking if the currently logged-in user has a specific capability or role; always use this before performing any privileged action and it supports both capability names (edit_posts) and meta capabilities with object IDs (edit_post, $post_id).

if ( current_user_can( 'manage_options' ) ) { // User is an administrator echo 'Welcome, Admin!'; } if ( current_user_can( 'edit_post', $post_id ) ) { // User can edit this specific post show_edit_button(); }

user_can()

Checks whether a specific user (by ID or WP_User object) has a given capability, useful when you need to verify permissions for a user other than the currently logged-in one, such as in background processes or when displaying user lists.

$user_id = 42; if ( user_can( $user_id, 'publish_posts' ) ) { // User #42 can publish posts } // With WP_User object $user = get_user_by( 'email', 'john@example.com' ); if ( user_can( $user, 'upload_files' ) ) { // This user can upload files }

Custom Capabilities

You can define your own capabilities for granular permission control in plugins/themes; these are stored in the database and should be added during activation and optionally removed during uninstallation.

// During plugin activation function myplugin_activate() { $admin = get_role( 'administrator' ); $admin->add_cap( 'manage_bookings' ); $admin->add_cap( 'view_booking_reports' ); } register_activation_hook( __FILE__, 'myplugin_activate' ); // Usage if ( current_user_can( 'manage_bookings' ) ) { // Show booking management interface }

Role and Capability System

WordPress uses a role-based access control (RBAC) system where roles are collections of capabilities stored in the wp_options table; default roles are Subscriber, Contributor, Author, Editor, and Administrator, each inheriting capabilities progressively.

┌──────────────────────────────────────────────────────────┐
│  wp_options → wp_user_roles (serialized array)           │
├──────────────────────────────────────────────────────────┤
│  'administrator' => [                                    │
│      'name' => 'Administrator',                          │
│      'capabilities' => [                                 │
│          'manage_options' => true,                       │
│          'edit_users' => true,                           │
│          'install_plugins' => true,                      │
│          ...200+ capabilities                            │
│      ]                                                   │
│  ]                                                       │
└──────────────────────────────────────────────────────────┘

add_cap()

Adds a capability to a role object; this writes to the database so should only be called during plugin activation or an admin action, never on every page load as it triggers a database write each time.

// ✅ Correct: During activation register_activation_hook( __FILE__, function() { $editor = get_role( 'editor' ); if ( $editor ) { $editor->add_cap( 'manage_custom_content' ); } }); // ❌ Wrong: On every request (DB write every time!) // add_action('init', function() { get_role('editor')->add_cap('...'); });

remove_cap()

Removes a capability from a role; like add_cap(), use only during deactivation/uninstallation to clean up custom capabilities your plugin added, keeping the database clean.

register_deactivation_hook( __FILE__, function() { $roles = [ 'administrator', 'editor' ]; foreach ( $roles as $role_name ) { $role = get_role( $role_name ); if ( $role ) { $role->remove_cap( 'manage_custom_content' ); } } });

add_role()

Creates a new custom role with specified capabilities; returns the WP_Role object on success or null if the role already exists, so always check before adding and only run during activation.

register_activation_hook( __FILE__, function() { add_role( 'booking_manager', 'Booking Manager', [ 'read' => true, 'manage_bookings' => true, 'view_reports' => true, 'edit_posts' => false, ] ); });

remove_role()

Deletes a custom role from the database; users assigned to this role will retain their user accounts but lose all capabilities from that role, so consider reassigning users first.

register_uninstall_hook( __FILE__, 'myplugin_uninstall' ); function myplugin_uninstall() { // Reassign users first (optional) $users = get_users( [ 'role' => 'booking_manager' ] ); foreach ( $users as $user ) { $user->set_role( 'subscriber' ); } // Then remove the role remove_role( 'booking_manager' ); }

Data Validation

┌─────────────────────────────────────────────────────────────────┐ │ VALIDATION FLOW │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ INPUT ──► [VALIDATE] ──► Valid? ──► YES ──► Process │ │ │ │ │ NO │ │ │ │ │ ▼ │ │ Reject/Error │ │ │ │ Validation = "Is this data what I expect?" │ │ (Does NOT modify data, only checks it) │ └─────────────────────────────────────────────────────────────────┘

is_email()

Validates whether a string is a properly formatted email address according to WordPress standards; returns the email if valid or false if invalid, making it useful in conditional checks before storing user input.

$email = $_POST['user_email']; if ( is_email( $email ) ) { update_user_meta( $user_id, 'email', sanitize_email( $email ) ); } else { $errors->add( 'invalid_email', 'Please enter a valid email address.' ); }

is_numeric()

A PHP function that checks if a variable is a number or numeric string (including floats and negative numbers); useful for validating price fields, quantities, or any numeric input before processing.

$price = $_POST['product_price']; if ( ! is_numeric( $price ) || $price < 0 ) { wp_die( 'Invalid price value' ); } $clean_price = floatval( $price );

absint()

WordPress function that returns the absolute integer value of a variable, effectively combining abs() and intval(); perfect for IDs and counts where you need a guaranteed non-negative integer.

$post_id = absint( $_GET['post_id'] ); // "42" → 42, "-5" → 5, "abc" → 0 // Common pattern for post operations $id = isset( $_GET['id'] ) ? absint( $_GET['id'] ) : 0; if ( $id > 0 ) { $post = get_post( $id ); }

intval()

PHP function that converts a value to an integer; unlike absint(), it preserves negative numbers and is useful when negative values are valid (like timezone offsets or balance adjustments).

$offset = intval( $_POST['timezone_offset'] ); // "-5" → -5 $quantity = intval( $_POST['quantity'] ); // "3.7" → 3 // Use absint() for IDs, intval() when negatives are valid

Type Checking

Using PHP's type-checking functions (is_array(), is_string(), is_bool(), is_object()) ensures data structures match expectations before processing, preventing type juggling vulnerabilities and unexpected behavior.

$settings = $_POST['settings'] ?? []; if ( ! is_array( $settings ) ) { wp_die( 'Invalid settings format' ); } if ( isset( $settings['enabled'] ) && ! is_bool( $settings['enabled'] ) ) { $settings['enabled'] = filter_var( $settings['enabled'], FILTER_VALIDATE_BOOLEAN ); }

Regex Validation

Using preg_match() for pattern-based validation allows complex format checking for phone numbers, custom IDs, or any structured data format that built-in functions don't cover.

$phone = $_POST['phone']; // Validate US phone format: (123) 456-7890 if ( ! preg_match( '/^\(\d{3}\) \d{3}-\d{4}$/', $phone ) ) { $errors->add( 'phone', 'Please enter phone as (XXX) XXX-XXXX' ); } $sku = $_POST['sku']; // Validate SKU format: ABC-12345 if ( ! preg_match( '/^[A-Z]{3}-\d{5}$/', $sku ) ) { $errors->add( 'sku', 'Invalid SKU format' ); }

Whitelist Validation

The most secure validation approach where you check input against an explicit list of allowed values; essential for select boxes, radio buttons, and any input with predefined valid options.

$allowed_status = [ 'draft', 'pending', 'publish' ]; $status = $_POST['post_status']; if ( ! in_array( $status, $allowed_status, true ) ) { // strict comparison! wp_die( 'Invalid status' ); } // For associative options $allowed_sizes = [ 'small' => 'Small (S)', 'medium' => 'Medium (M)', 'large' => 'Large (L)', ]; if ( ! array_key_exists( $_POST['size'], $allowed_sizes ) ) { wp_die( 'Invalid size selected' ); }

Data Sanitization

┌─────────────────────────────────────────────────────────────────┐ │ SANITIZATION FLOW │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ UNTRUSTED ┌────────────┐ CLEAN │ │ INPUT ───► │ SANITIZE │ ───► DATA ───► DATABASE │ │ └────────────┘ │ │ │ │ "<script>Hi</script>" → sanitize_text_field() → "Hi" │ │ │ │ Sanitization = "Clean the data for safe storage" │ │ (Modifies/strips dangerous content) │ └─────────────────────────────────────────────────────────────────┘

sanitize_text_field()

Removes tags, encodes special characters, strips extra whitespace, and removes line breaks from a string; the go-to function for single-line text inputs like names, titles, or short descriptions.

$name = sanitize_text_field( $_POST['customer_name'] ); // "<b>John</b> Doe\n" → "John Doe" update_post_meta( $post_id, 'customer_name', $name );

sanitize_textarea_field()

Similar to sanitize_text_field() but preserves line breaks (\n); use for multi-line text inputs where formatting with newlines should be maintained, like addresses or notes.

$address = sanitize_textarea_field( $_POST['shipping_address'] ); // Preserves line breaks, removes HTML tags // "123 Main St\nApt 4\nNew York" → kept intact update_user_meta( $user_id, 'address', $address );

sanitize_email()

Strips all characters not allowed in email addresses, leaving only valid email characters; returns an empty string if the result isn't a valid email format, so always validate with is_email() first.

$email = $_POST['email']; if ( is_email( $email ) ) { $clean_email = sanitize_email( $email ); // "John <john@test.com>!" → "john@test.com" update_option( 'admin_email', $clean_email ); }

sanitize_url()

Cleans a URL by removing invalid characters and dangerous protocols; preserves only whitelisted protocols (http, https, ftp, etc.) and is essential for any user-provided URLs.

$website = sanitize_url( $_POST['website'] ); // "javascript:alert(1)" → "" // "https://example.com/<script>" → "https://example.com/" // For more control over allowed protocols $url = esc_url_raw( $_POST['link'], [ 'http', 'https' ] );

sanitize_title()

Converts a string into a URL-friendly slug by removing special characters, converting spaces to hyphens, and lowercasing; used internally by WordPress for generating post slugs.

$slug = sanitize_title( 'Hello World! This is a Test' ); // Returns: "hello-world-this-is-a-test" $slug = sanitize_title( $_POST['custom_slug'] ); wp_update_post( [ 'ID' => $post_id, 'post_name' => $slug ] );

sanitize_file_name()

Removes special characters, replaces spaces with dashes, and makes filenames safe for filesystem storage; critical for any file upload handling to prevent directory traversal attacks.

$filename = sanitize_file_name( $_FILES['upload']['name'] ); // "../../../etc/passwd" → "etc-passwd" // "my file (1).jpg" → "my-file-1.jpg" $upload_path = wp_upload_dir()['path'] . '/' . $filename;

sanitize_key()

Lowercases the string and removes everything except alphanumerics, dashes, and underscores; perfect for option names, meta keys, and any string used as an array key or database identifier.

$key = sanitize_key( $_POST['option_name'] ); // "My Option-Name!" → "my_option-name" update_option( $key, $value ); // Safe to use as option name

sanitize_html_class()

Sanitizes a string for use as an HTML class name, keeping only A-Z, a-z, 0-9, hyphens, and underscores; the first character must be a letter, making it safe for CSS class attributes.

$class = sanitize_html_class( $_POST['custom_class'] ); // "my-class <script>" → "my-class" echo '<div class="widget ' . esc_attr( $class ) . '">';

wp_kses()

The most powerful sanitization function that filters HTML to only allow specified tags and attributes; you define exactly which elements are permitted, giving precise control over allowed markup.

$allowed = [ 'a' => [ 'href' => [], 'title' => [], 'target' => [], ], 'strong' => [], 'em' => [], 'br' => [], ]; $clean_html = wp_kses( $_POST['bio'], $allowed ); // Only <a>, <strong>, <em>, <br> survive, with controlled attributes

wp_kses_post()

A convenience wrapper around wp_kses() that allows the same HTML tags permitted in post content; ideal for sanitizing rich text editor output or content that should match post formatting rules.

$content = wp_kses_post( $_POST['description'] ); // Allows: <p>, <a>, <strong>, <em>, <ul>, <ol>, <li>, <blockquote>, etc. // Strips: <script>, <iframe>, <form>, onclick attributes, etc. update_post_meta( $post_id, 'description', $content );

sanitize_option()

Applies the registered sanitization callback for a specific WordPress option; used internally by update_option() and useful when you need to manually sanitize option values using their registered filters.

// WordPress internally uses this for known options $email = sanitize_option( 'admin_email', $_POST['email'] ); $url = sanitize_option( 'siteurl', $_POST['url'] ); // Register custom option sanitization register_setting( 'my_plugin', 'my_option', [ 'sanitize_callback' => 'absint' ]);

Custom Sanitization Callbacks

For complex data structures or specific business rules, create custom sanitization functions that combine multiple techniques and handle edge cases specific to your use case.

function sanitize_product_data( $input ) { return [ 'name' => sanitize_text_field( $input['name'] ?? '' ), 'price' => floatval( $input['price'] ?? 0 ), 'sku' => preg_replace( '/[^A-Z0-9\-]/', '', strtoupper( $input['sku'] ?? '' ) ), 'tags' => array_map( 'sanitize_text_field', (array) ( $input['tags'] ?? [] ) ), 'featured' => ! empty( $input['featured'] ), ]; } register_setting( 'products', 'product_settings', [ 'sanitize_callback' => 'sanitize_product_data', ]);

Data Escaping

┌─────────────────────────────────────────────────────────────────┐ │ ESCAPING FLOW │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ DATABASE ───► ┌────────────┐ ───► BROWSER │ │ │ ESCAPE │ (HTML/JS/URL) │ │ └────────────┘ │ │ │ │ Context-aware escaping: │ │ ┌─────────────────┬─────────────────────────────────────┐ │ │ │ HTML body │ esc_html() │ │ │ │ HTML attributes │ esc_attr() │ │ │ │ URLs │ esc_url() │ │ │ │ JavaScript │ esc_js() or wp_json_encode() │ │ │ │ Textareas │ esc_textarea() │ │ │ │ SQL queries │ esc_sql() or $wpdb->prepare() │ │ │ └─────────────────┴─────────────────────────────────────┘ │ │ │ │ RULE: Escape LATE, as close to output as possible! │ └─────────────────────────────────────────────────────────────────┘

esc_html()

Encodes HTML special characters (<, >, &, ", ') into their entity equivalents, preventing any HTML from being rendered; use when outputting any variable inside HTML tags but not within attributes.

<h1><?php echo esc_html( $title ); ?></h1> <p><?php echo esc_html( $user_bio ); ?></p> // "<script>alert('xss')</script>" // becomes "&lt;script&gt;alert('xss')&lt;/script&gt;" // Displays as text, not executed

esc_attr()

Similar to esc_html() but specifically designed for escaping values inside HTML attributes; encodes quotes and special characters to prevent attribute breakout attacks.

<input type="text" name="username" value="<?php echo esc_attr( $username ); ?>" placeholder="<?php echo esc_attr( $placeholder ); ?>"> <div data-config="<?php echo esc_attr( $json_config ); ?>"> // " onclick="evil()" → &quot; onclick=&quot;evil()&quot;

esc_url()

Sanitizes URLs for safe output by encoding special characters and rejecting dangerous protocols (javascript:, data:, etc.); use for any URL in href, src, or other URL attributes.

<a href="<?php echo esc_url( $website ); ?>">Visit Site</a> <img src="<?php echo esc_url( $avatar_url ); ?>" alt="Avatar"> // For database storage, use esc_url_raw() instead (doesn't encode ampersands) $clean_url = esc_url_raw( $url ); update_option( 'external_api', $clean_url );

esc_js()

Escapes strings for safe inclusion in inline JavaScript by escaping quotes, backslashes, and special characters; however, prefer wp_json_encode() for passing data to JavaScript.

<script> var message = '<?php echo esc_js( $message ); ?>'; alert(message); </script> // Better approach: use wp_localize_script() or wp_json_encode()

esc_textarea()

Encodes text for safe output inside <textarea> elements; handles the special case where content appears between tags rather than in an attribute, preserving line breaks while preventing HTML injection.

<textarea name="description" rows="5"> <?php echo esc_textarea( $description ); ?> </textarea> // Don't use esc_html() in textareas - use esc_textarea()!

esc_sql()

Escapes strings for use in SQL queries by adding slashes before special characters; however, always prefer $wpdb->prepare() instead, which is safer and handles multiple data types.

// ⚠️ Works but not recommended $name = esc_sql( $search_term ); $wpdb->query( "SELECT * FROM {$wpdb->posts} WHERE post_title = '$name'" ); // ✅ Preferred method: $wpdb->prepare() $results = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->posts} WHERE post_title = %s AND post_status = %s", $search_term, 'publish' ) );

wp_json_encode()

Encodes PHP data as JSON with proper escaping for safe output in JavaScript contexts; this is the preferred method for passing complex data from PHP to JavaScript.

$settings = [ 'ajax_url' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'my_action' ), 'user' => get_current_user_id(), 'message' => "Hello <script>alert('xss')</script>", ]; <script> var wpSettings = <?php echo wp_json_encode( $settings ); ?>; // Safe: {"ajax_url":"...","message":"Hello <script>..."} </script> // Or better, use wp_add_inline_script(): wp_add_inline_script( 'my-script', 'var wpSettings = ' . wp_json_encode( $settings ) . ';', 'before' );

Late Escaping Principle

Escape data at the very last moment before output, not when storing or retrieving; this ensures the correct escaping context is used and prevents double-escaping or missing escaping when code changes.

// ❌ Wrong: Escaping too early $title = esc_html( get_the_title() ); // Stored escaped // ... 50 lines later ... echo $title; // What context? HTML? Attribute? Unknown! // ✅ Correct: Escape at output $title = get_the_title(); // Raw data // ... later in template ... echo '<h1>' . esc_html( $title ) . '</h1>'; echo '<input value="' . esc_attr( $title ) . '">'; echo '<a href="' . esc_url( $link ) . '">' . esc_html( $title ) . '</a>'; // Even better: Use helper functions the_title(); // Already escapes for HTML context

┌─────────────────────────────────────────────────────────────────┐
│                  COMPLETE SECURITY FLOW                         │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│   USER INPUT                                                    │
│       │                                                         │
│       ▼                                                         │
│   ┌─────────────┐                                               │
│   │ 1. NONCE    │  ← Verify request authenticity                │
│   └─────────────┘                                               │
│       │                                                         │
│       ▼                                                         │
│   ┌─────────────┐                                               │
│   │ 2. CAPABILITY│ ← Check user permissions                     │
│   └─────────────┘                                               │
│       │                                                         │
│       ▼                                                         │
│   ┌─────────────┐                                               │
│   │ 3. VALIDATE │  ← Is the data format correct?                │
│   └─────────────┘                                               │
│       │                                                         │
│       ▼                                                         │
│   ┌─────────────┐                                               │
│   │ 4. SANITIZE │  ← Clean data for storage                     │
│   └─────────────┘                                               │
│       │                                                         │
│       ▼                                                         │
│   ┌─────────────┐                                               │
│   │ 5. DATABASE │  ← Store clean data                           │
│   └─────────────┘                                               │
│       │                                                         │
│       ▼                                                         │
│   ┌─────────────┐                                               │
│   │ 6. ESCAPE   │  ← Safe output to browser                     │
│   └─────────────┘                                               │
│       │                                                         │
│       ▼                                                         │
│     OUTPUT                                                      │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

SQL Injection Prevention

$wpdb->prepare()

The $wpdb->prepare() method is WordPress's primary defense against SQL injection, creating parameterized queries that separate SQL logic from user data, ensuring malicious input cannot alter query structure.

// ❌ VULNERABLE - Never do this $wpdb->query("SELECT * FROM {$wpdb->posts} WHERE ID = $user_input"); // ✅ SAFE - Always use prepare() $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->posts} WHERE ID = %d AND post_status = %s", $user_input, 'publish' ) );

Placeholder Types

WordPress uses three placeholder types in prepare(): %s for strings (quoted automatically), %d for integers (sanitized to whole numbers), and %f for floats, each applying appropriate sanitization for its data type.

┌─────────────┬─────────────────┬──────────────────────────┐
│ Placeholder │ Type            │ Example Output           │
├─────────────┼─────────────────┼──────────────────────────┤
│ %d          │ Integer         │ 42                       │
│ %f          │ Float           │ 3.14                     │
│ %s          │ String          │ 'hello world'            │
│ %i          │ Identifier      │ `column_name` (WP 6.2+)  │
└─────────────┴─────────────────┴──────────────────────────┘

// Usage example
$wpdb->prepare(
    "SELECT %i FROM {$wpdb->posts} WHERE ID = %d AND title = %s",
    $column,    // %i - identifier (column/table name)
    $id,        // %d - integer
    $title      // %s - string
);

Safe Query Building

Build complex queries safely by combining $wpdb->prepare() with proper table references using $wpdb->prefix or built-in table properties, avoiding string concatenation with user input at all costs.

// Safe complex query building function get_filtered_posts($author_id, $status, $limit) { global $wpdb; $sql = "SELECT p.ID, p.post_title, u.display_name FROM {$wpdb->posts} p INNER JOIN {$wpdb->users} u ON p.post_author = u.ID WHERE p.post_author = %d AND p.post_status = %s ORDER BY p.post_date DESC LIMIT %d"; return $wpdb->get_results( $wpdb->prepare($sql, $author_id, $status, $limit) ); } // Dynamic WHERE clauses - safe approach function build_dynamic_query($filters) { global $wpdb; $where = ["1=1"]; $values = []; if (!empty($filters['author'])) { $where[] = "post_author = %d"; $values[] = $filters['author']; } if (!empty($filters['status'])) { $where[] = "post_status = %s"; $values[] = $filters['status']; } $sql = "SELECT * FROM {$wpdb->posts} WHERE " . implode(' AND ', $where); return empty($values) ? $wpdb->get_results($sql) : $wpdb->get_results($wpdb->prepare($sql, $values)); }

Query Escaping

While prepare() is preferred, esc_sql() and $wpdb->esc_like() provide additional escaping for edge cases like LIKE clauses, ensuring special SQL characters don't break queries or enable injection.

// esc_sql() - for rare cases where prepare() won't work $safe_value = esc_sql($user_input); // LIKE queries - must escape wildcards separately $search = $wpdb->esc_like($user_search); // Escapes %, _, and \ $wpdb->prepare( "SELECT * FROM {$wpdb->posts} WHERE post_title LIKE %s", '%' . $search . '%' // Add wildcards AFTER escaping ); // ┌──────────────────────────────────────────────────────────┐ // │ ESCAPING FLOW FOR LIKE QUERIES │ // ├──────────────────────────────────────────────────────────┤ // │ User Input: "50% off_sale" │ // │ ↓ │ // │ esc_like(): "50\% off\_sale" (escapes SQL wildcards) │ // │ ↓ │ // │ Add wildcards: "%50\% off\_sale%" │ // │ ↓ │ // │ prepare(): Further escapes for SQL safety │ // └──────────────────────────────────────────────────────────┘

XSS Prevention

Output Escaping

WordPress provides context-specific escaping functions that must be applied immediately before output, converting potentially dangerous characters to their HTML entities based on where the data will be rendered.

// Escape for HTML content echo esc_html($user_name); // Escape for HTML attributes echo '<input value="' . esc_attr($value) . '">'; // Escape for URLs echo '<a href="' . esc_url($link) . '">Click</a>'; // Escape for JavaScript echo '<script>var data = ' . esc_js($data) . ';</script>'; // Escape for textarea content echo '<textarea>' . esc_textarea($content) . '</textarea>'; // ┌─────────────────────────────────────────────────────────────┐ // │ CONTEXT-BASED ESCAPING CHEAT SHEET │ // ├────────────────────┬────────────────────────────────────────┤ // │ Context │ Function │ // ├────────────────────┼────────────────────────────────────────┤ // │ HTML body │ esc_html() │ // │ HTML attribute │ esc_attr() │ // │ URL/href/src │ esc_url() │ // │ JavaScript string │ esc_js() │ // │ Textarea content │ esc_textarea() │ // │ CSS │ safecss_filter_attr() │ // │ SQL │ $wpdb->prepare() │ // └────────────────────┴────────────────────────────────────────┘

Content Security Policy

CSP is an HTTP header that restricts which resources can load on your page, providing a powerful second layer of XSS defense by blocking inline scripts and limiting script sources even if escaping fails.

// Add CSP header in WordPress add_action('send_headers', function() { header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-" . wp_create_nonce('csp') . "'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; frame-ancestors 'none'; "); }); // For inline scripts that need to work with CSP add_action('wp_enqueue_scripts', function() { $nonce = wp_create_nonce('csp'); wp_add_inline_script('my-script', 'console.log("safe");', 'before'); }); // ┌─────────────────────────────────────────────────────────────┐ // │ CSP DIRECTIVES │ // ├─────────────────────┬───────────────────────────────────────┤ // │ default-src │ Fallback for other directives │ // │ script-src │ JavaScript sources │ // │ style-src │ CSS sources │ // │ img-src │ Image sources │ // │ connect-src │ AJAX/WebSocket connections │ // │ frame-ancestors │ Who can embed (clickjacking defense) │ // └─────────────────────┴───────────────────────────────────────┘

Allowed HTML Filtering

The wp_kses() family of functions strips all HTML except explicitly allowed tags and attributes, essential for user-generated content where you need some formatting but must prevent script injection.

// wp_kses_post() - allows tags safe for post content $safe_content = wp_kses_post($user_html); // wp_kses() - custom allowed tags $allowed = [ 'a' => [ 'href' => [], 'title' => [], 'target' => [], ], 'strong' => [], 'em' => [], 'p' => ['class' => []], ]; $safe = wp_kses($user_input, $allowed); // wp_kses_data() - minimal set for data $safe_data = wp_kses_data($input); // ┌─────────────────────────────────────────────────────────────┐ // │ KSES FUNCTION COMPARISON │ // ├─────────────────────┬───────────────────────────────────────┤ // │ wp_kses() │ Custom whitelist - most control │ // │ wp_kses_post() │ Same as post content - rich HTML │ // │ wp_kses_data() │ Very limited - basic formatting │ // │ wp_strip_all_tags() │ Removes ALL HTML - plain text only │ // └─────────────────────┴───────────────────────────────────────┘ // Extending allowed HTML globally add_filter('wp_kses_allowed_html', function($allowed, $context) { if ($context === 'post') { $allowed['iframe'] = [ 'src' => [], 'width' => [], 'height' => [], ]; } return $allowed; }, 10, 2);

CSRF Protection

Nonce Implementation

WordPress nonces are cryptographic tokens tied to a specific action, user, and time window (24-48 hours lifespan), ensuring form submissions and AJAX requests originate from your site by legitimate users.

// FORM IMPLEMENTATION // Creating nonce field function my_custom_form() { ?> <form method="post" action=""> <?php wp_nonce_field('my_action_name', 'my_nonce_field'); ?> <input type="text" name="data"> <button type="submit">Submit</button> </form> <?php } // Verifying nonce on submission function process_form() { if (!isset($_POST['my_nonce_field']) || !wp_verify_nonce($_POST['my_nonce_field'], 'my_action_name')) { wp_die('Security check failed'); } // Process form safely... } // URL NONCES (for action links) $action_url = wp_nonce_url( admin_url('admin.php?action=delete&id=5'), 'delete_item_5' ); // Result: admin.php?action=delete&id=5&_wpnonce=abc123 // ┌─────────────────────────────────────────────────────────────┐ // │ NONCE LIFECYCLE │ // │ │ // │ Create ──────► Embed ──────► Submit ──────► Verify │ // │ │ │ │ │ │ // │ wp_nonce_* Form/URL User Action wp_verify_nonce │ // │ │ // │ Valid for: 12-24 hours (tick 1) or 24-48 hours (tick 0) │ // └─────────────────────────────────────────────────────────────┘

Referer Checking

The check_admin_referer() function combines nonce verification with HTTP referer validation, providing defense-in-depth for admin pages by confirming requests came from within the WordPress admin area.

// Admin page form processing add_action('admin_post_my_action', function() { // Checks both nonce AND referer check_admin_referer('my_action_nonce', 'my_nonce_field'); // Process action... wp_redirect(admin_url('admin.php?page=my-plugin&updated=1')); exit; }); // For AJAX requests (different function) add_action('wp_ajax_my_ajax_action', function() { check_ajax_referer('my_ajax_nonce', 'security'); // Process AJAX... wp_send_json_success(['message' => 'Done']); }); // ┌─────────────────────────────────────────────────────────────┐ // │ REFERER CHECK FUNCTIONS │ // ├─────────────────────────┬───────────────────────────────────┤ // │ check_admin_referer() │ Admin forms - nonce + referer │ // │ check_ajax_referer() │ AJAX calls - nonce + referer │ // │ wp_verify_nonce() │ Nonce only - more flexible │ // │ wp_referer_field() │ Add hidden referer field │ // └─────────────────────────┴───────────────────────────────────┘

Token Validation

Proper token validation includes checking return values from verification functions, handling expired nonces gracefully, and implementing appropriate error responses without exposing security details.

// Complete AJAX implementation with proper validation add_action('wp_ajax_secure_action', 'handle_secure_action'); function handle_secure_action() { // Verify nonce - returns false, 1, or 2 $nonce_check = wp_verify_nonce($_POST['nonce'], 'secure_action'); if ($nonce_check === false) { wp_send_json_error(['message' => 'Invalid request'], 403); } if ($nonce_check === 2) { // Nonce is valid but old (12-24 hours) // Optionally refresh the nonce wp_send_json_success([ 'data' => $result, 'new_nonce' => wp_create_nonce('secure_action') ]); } // Capability check after nonce validation if (!current_user_can('edit_posts')) { wp_send_json_error(['message' => 'Permission denied'], 403); } // Safe to process... } // ┌─────────────────────────────────────────────────────────────┐ // │ wp_verify_nonce() RETURN VALUES │ // ├─────────┬───────────────────────────────────────────────────┤ // │ false │ Invalid nonce - reject request │ // │ 1 │ Valid, generated 0-12 hours ago │ // │ 2 │ Valid, generated 12-24 hours ago (consider refresh)│ // └─────────┴───────────────────────────────────────────────────┘ // Frontend script with nonce add_action('wp_enqueue_scripts', function() { wp_enqueue_script('my-script', '...', [], '1.0', true); wp_localize_script('my-script', 'myAjax', [ 'url' => admin_url('admin-ajax.php'), 'nonce' => wp_create_nonce('secure_action') ]); });

File Security

File Type Validation

Never trust user-provided file extensions; validate against an explicit whitelist of allowed types, check the file content, and regenerate safe filenames to prevent execution of malicious uploads.

// Validate file type on upload add_filter('wp_handle_upload_prefilter', function($file) { $allowed_types = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf']; // Check extension $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); $allowed_ext = ['jpg', 'jpeg', 'png', 'gif', 'pdf']; if (!in_array($ext, $allowed_ext)) { $file['error'] = 'File type not allowed'; return $file; } return $file; }); // Custom upload validation function validate_upload($file) { $allowed = [ 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'pdf' => 'application/pdf' ]; $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); // Check if extension is allowed if (!array_key_exists($ext, $allowed)) { return new WP_Error('invalid_type', 'Extension not allowed'); } return true; } // ┌─────────────────────────────────────────────────────────────┐ // │ FILE VALIDATION LAYERS │ // │ │ // │ Layer 1: Extension Check → Quick filter │ // │ Layer 2: MIME Type Check → Content verification │ // │ Layer 3: File Content Check → Magic bytes/headers │ // │ Layer 4: Rename File → Remove original name │ // │ Layer 5: Move to Safe Dir → Outside web root if poss. │ // └─────────────────────────────────────────────────────────────┘

MIME Type Checking

Verify actual file content against claimed MIME types using PHP's finfo functions or WordPress's wp_check_filetype_and_ext(), as attackers often rename malicious files with innocent extensions.

// WordPress built-in MIME checking function verify_file_mime($file_path, $filename) { $check = wp_check_filetype_and_ext($file_path, $filename); // Returns array with: // 'ext' - Corrected extension // 'type' - Corrected MIME type // 'proper_filename' - Corrected filename if needed if (!$check['type']) { return false; // Unrecognized or mismatched type } return $check; } // Manual MIME verification using finfo function check_real_mime($file_path) { $finfo = finfo_open(FILEINFO_MIME_TYPE); $real_mime = finfo_file($finfo, $file_path); finfo_close($finfo); $allowed_mimes = [ 'image/jpeg', 'image/png', 'image/gif', 'application/pdf' ]; return in_array($real_mime, $allowed_mimes); } // Check magic bytes for images function verify_image($file_path) { $image_info = @getimagesize($file_path); if ($image_info === false) { return false; // Not a valid image } $allowed = [IMAGETYPE_JPEG, IMAGETYPE_PNG, IMAGETYPE_GIF]; return in_array($image_info[2], $allowed); } // ┌─────────────────────────────────────────────────────────────┐ // │ COMMON FILE SIGNATURES (Magic Bytes) │ // ├─────────────────────┬───────────────────────────────────────┤ // │ JPEG │ FF D8 FF │ // │ PNG │ 89 50 4E 47 0D 0A 1A 0A │ // │ GIF │ 47 49 46 38 │ // │ PDF │ 25 50 44 46 │ // │ PHP (danger!) │ 3C 3F 70 68 70 (<?php)// └─────────────────────┴───────────────────────────────────────┘

Upload Restrictions

Implement multiple layers of upload restrictions including file size limits, quantity limits, user capability checks, and storage quotas to prevent resource exhaustion and unauthorized uploads.

// Restrict upload types per user role add_filter('upload_mimes', function($mimes) { // Remove dangerous types unset($mimes['exe']); unset($mimes['php']); unset($mimes['js']); // Restrict non-admins if (!current_user_can('administrator')) { return [ 'jpg|jpeg' => 'image/jpeg', 'png' => 'image/png', 'pdf' => 'application/pdf', ]; } return $mimes; }); // Limit file size add_filter('wp_handle_upload_prefilter', function($file) { $max_size = 5 * 1024 * 1024; // 5MB if ($file['size'] > $max_size) { $file['error'] = 'File exceeds maximum size of 5MB'; } return $file; }); // Complete upload handler with restrictions function handle_custom_upload($file_input) { // Check capability if (!current_user_can('upload_files')) { return new WP_Error('no_permission', 'Cannot upload'); } // Rate limiting $recent = get_user_meta(get_current_user_id(), 'upload_count_hour', true); if ($recent > 20) { return new WP_Error('rate_limit', 'Too many uploads'); } // Use WordPress upload handler $upload = wp_handle_upload($file_input, ['test_form' => false]); if (isset($upload['error'])) { return new WP_Error('upload_error', $upload['error']); } // Update rate limit counter update_user_meta(get_current_user_id(), 'upload_count_hour', $recent + 1); return $upload; }

Path Traversal Prevention

Sanitize all file paths and names using WordPress functions, validate that resolved paths remain within expected directories, and never use user input directly in file operations to prevent directory traversal attacks.

// Sanitize filename $safe_name = sanitize_file_name($user_filename); // "../../etc/passwd" → "etc-passwd" // "file<script>.php" → "filescript.php" // Validate path stays within allowed directory function safe_file_path($user_path, $base_dir) { // Normalize the paths $base_dir = wp_normalize_path(realpath($base_dir)); // Build full path and resolve it $full_path = wp_normalize_path($base_dir . '/' . $user_path); $real_path = realpath($full_path); // Verify it exists and is within base directory if ($real_path === false) { return false; // File doesn't exist } $real_path = wp_normalize_path($real_path); // Check that resolved path starts with base directory if (strpos($real_path, $base_dir) !== 0) { return false; // Path traversal attempt! } return $real_path; } // Example usage $base = WP_CONTENT_DIR . '/uploads/my-plugin'; $user_input = $_GET['file']; // e.g., "../../../wp-config.php" $safe_path = safe_file_path($user_input, $base); if ($safe_path === false) { wp_die('Invalid file path'); } // ┌─────────────────────────────────────────────────────────────┐ // │ PATH TRAVERSAL ATTACK PATTERNS │ // ├─────────────────────────────────────────────────────────────┤ // │ ../../../etc/passwd Basic traversal │ // │ ....//....//etc/passwd Filter bypass │ // │ ..%2f..%2f..%2fetc/passwd URL encoding │ // │ ..%252f..%252fetc/passwd Double encoding │ // │ /var/www/html/../../../etc Absolute path injection │ // └─────────────────────────────────────────────────────────────┘ // ┌─────────────────────────────────────────────────────────────┐ // │ SAFE FILE OPERATIONS PATTERN │ // │ │ // │ User Input │ // │ ↓ │ // │ sanitize_file_name() Remove dangerous characters │ // │ ↓ │ // │ Build Path Combine with base directory │ // │ ↓ │ // │ realpath() Resolve to actual path │ // │ ↓ │ // │ Verify Within Base strpos() check │ // │ ↓ │ // │ File Operation Safe to proceed │ // └─────────────────────────────────────────────────────────────┘

Complete Security Checklist

┌─────────────────────────────────────────────────────────────────┐ │ WORDPRESS SECURITY CHECKLIST │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ INPUT (Never Trust) │ │ ├── $_GET, $_POST, $_REQUEST → Always validate │ │ ├── $_FILES → Validate type & content │ │ └── Database values → May contain old bad data │ │ │ │ PROCESS (Validate & Sanitize) │ │ ├── Capability check → current_user_can() │ │ ├── Nonce verification → wp_verify_nonce() │ │ ├── Data validation → Type checking │ │ └── Sanitization → sanitize_*() functions │ │ │ │ DATABASE (Prepared Statements) │ │ └── All queries → $wpdb->prepare() │ │ │ │ OUTPUT (Escape Everything) │ │ ├── HTML content → esc_html() │ │ ├── Attributes → esc_attr() │ │ ├── URLs → esc_url() │ │ └── JavaScript → esc_js() or json_encode │ │ │ └─────────────────────────────────────────────────────────────────┘