Prevent SQL Injection and XSS in WordPress Plugins with Prepared Statements and Escaping

SQL injection and cross-site scripting (XSS) are the two most common vulnerability classes in WordPress plugins — SQL injection exploits unsanitized input concatenated into database queries, while XSS exploits unescaped output rendered as HTML in the browser. WordPress provides a complete set of functions for both prevention layers: $wpdb->prepare() for parameterized SQL queries, and a family of context-specific escaping functions (esc_html(), esc_attr(), esc_url(), esc_js(), wp_kses()) for output. $wpdb->prepare() accepts a query string with %s, %d, and %f placeholders and a variadic list of values — it quotes and escapes string values, casts integers and floats, and cannot be bypassed by injection payloads in the values. Never use string interpolation or concatenation to build SQL queries with user input — even after sanitizing the input, a missed edge case can still allow injection. Output escaping must happen at the point of output, not at the point of input — sanitizing on input and echoing raw values later is unsafe because the sanitized value may still contain characters that are dangerous in an HTML context. The escaping function must match the HTML context: esc_html() for text nodes, esc_attr() for HTML attribute values, esc_url() for href and src attributes, and wp_kses_post() for rich text that should allow a limited set of HTML tags. The wp_nonce_field() and check_admin_referer() pair prevents CSRF on form submissions — nonces are single-use tokens tied to the action, user, and session that expire after 12–24 hours. Stored XSS via the WordPress editor is mitigated by wp_kses_post() on save and wp_kses_post() or the_content() on output — the the_content filter applies wptexturize(), wpautop(), and other filters that also normalize dangerous markup. The custom product fields post applies these same patterns — wc_clean() for input sanitization and esc_html() for output escaping.

Problem: A plugin shortcode accepts a category ID from a URL query parameter and passes it directly into a $wpdb->query() call, and its output is echoed without escaping — creating a SQL injection point and a reflected XSS vulnerability accessible to any visitor.

Solution: Validate and cast the input to an integer with absint(), use $wpdb->prepare() with a %d placeholder for all database queries, and escape every output value with the context-appropriate escaping function at the point of rendering.

// ── BAD: SQL injection + XSS vulnerability ───────────────────────────────
// NEVER do this:
function bad_shortcode($atts): string {
    global $wpdb;
    $cat = $_GET['cat'];  // unsanitized user input
    // SQL injection: attacker can append UNION SELECT or DROP TABLE
    $results = $wpdb->get_results("SELECT * FROM {$wpdb->posts} WHERE post_category = $cat");
    $out = '';
    foreach ($results as $row) {
        // XSS: if post_title contains