14+ years building on WordPress / Replies in under 5 hours
Security 10 min read · Updated July 2026

Sanitization vs. Validation: Securing All User Input in WordPress (A Developer’s Guide)

Photo of Ajay Khandal
Ajay Khandal
WordPress Developer
Sanitization vs. Validation: Securing All User Input in WordPress (A Developer’s Guide)
TL;DR

Validation checks whether input has the right shape and rejects it if not; sanitization cleans accepted input before storage; escaping encodes stored values at the point of output. All three must run in order — validate first, sanitize before the database write, escape at every echo. Skipping any stage, or running them out of order, leaves your WordPress forms, meta boxes, and REST endpoints open to SQL injection, XSS, and CSRF attacks.

Every WordPress form, AJAX handler, REST endpoint, and settings page that accepts external data is a potential attack vector. SQL injection, cross-site scripting (XSS), and cross-site request forgery (CSRF) — three of the most common classes of WordPress vulnerabilities — all trace back to the same root cause: untrusted input that was never properly checked, cleaned, or encoded before use.

WordPress gives you a purpose-built toolkit for this. But the toolkit only works if you understand the three-stage pipeline — validate → sanitize → escape — and apply the right function at each stage. This guide covers every stage with concrete code examples, including a full end-to-end meta box example and a look at how the pipeline applies to REST API parameters.

Validation, Sanitization, and Escaping: Three Different Jobs

These three terms are often used interchangeably, but they do different things at different points in the data lifecycle:

Stage Question it answers Output When to run it
Validation Is this the right shape? Boolean (accept/reject) Immediately on receipt — before any processing
Sanitization Can I make this safe to store? Cleaned string/value Before writing to the database
Escaping Is this safe to render here? Encoded string At the point of output — never before

The rule is always: validate first, sanitize before storage, escape at output. Running these out of order — sanitizing before validating, or storing without escaping on output — is how vulnerabilities get introduced even in code that looks security-conscious.

Step 1: Validation — Checking the Shape of Input

Validation answers a yes/no question. If the answer is no, reject the input and return an error. Never attempt to salvage malformed input by sanitizing it into a workable shape — that approach creates subtle bugs and security gaps.

Nonce verification — always first

Before touching any field in $_POST or $_GET, verify the nonce. A missing or invalid nonce means the request did not originate from your form, and you should reject everything else unconditionally:

// In a save_post callback or AJAX handler
if ( ! isset( $_POST['_my_nonce'] ) || ! wp_verify_nonce( $_POST['_my_nonce'], 'my_meta_save_' . $post_id ) ) {
    return;
}

Capability checks

Immediately after nonce verification, confirm the current user has permission to perform the operation. For posts/pages with a custom meta box, edit_post is the correct capability to check:

if ( ! current_user_can( 'edit_post', $post_id ) ) {
    return;
}

See the guide on WordPress custom user roles and capabilities for the full hierarchy of built-in capabilities and how to map meta capabilities to object IDs correctly.

Type and format validation

WordPress and PHP together cover the most common formats:

Input type Function What it checks
Email is_email() Valid email format (returns the address or false)
Integer is_numeric() Numeric string or value
URL wp_http_validate_url() Full URL with scheme and host
Custom pattern preg_match() Phone numbers, postcodes, UUID, etc.
Allowed values in_array() Enum-style: one of a known set of options
// Email
$email = $_POST['user_email'] ?? '';
if ( ! is_email( $email ) ) {
    wp_send_json_error( 'Invalid email address.' );
}

// Integer in range
$quantity = $_POST['quantity'] ?? '';
if ( ! is_numeric( $quantity ) || (int) $quantity < 1 || (int) $quantity > 100 ) {
    wp_send_json_error( 'Quantity must be a number between 1 and 100.' );
}

// Enum: only these values are allowed
$status = $_POST['order_status'] ?? '';
$allowed = array( 'pending', 'processing', 'complete' );
if ( ! in_array( $status, $allowed, true ) ) {
    wp_send_json_error( 'Invalid status value.' );
}

Step 2: Sanitization — Cleaning Data Before Storage

Once input has passed validation, sanitize it before writing to the database. Sanitization never rejects data — it strips or transforms anything that doesn’t belong. Choose the function that matches the storage context, not the input context.

Text and textarea

// Single-line text: names, labels, titles
$label = sanitize_text_field( $_POST['label'] );

// Multi-line text: notes, plain-text descriptions (strips HTML, preserves newlines)
$notes = sanitize_textarea_field( $_POST['notes'] );

Emails, URLs, keys, and classes

Data type Function What it strips
Email address sanitize_email() Illegal characters, HTML tags
URL for database storage esc_url_raw() Illegal characters; enforces valid URL structure
Option key / slug sanitize_key() Uppercases, special chars; returns lowercase alphanumeric + hyphens/underscores only
Post/term slug sanitize_title() Special chars; produces URL-safe slug
CSS class name sanitize_html_class() Chars not valid in a CSS class selector
Filename sanitize_file_name() Special chars unsafe in a filename; lowercases

Integers and IDs

// IDs and counts: use absint() — always non-negative
$post_id   = absint( $_POST['post_id'] );
$quantity  = absint( $_POST['quantity'] );

// Signed integers (e.g. temperature offsets)
$offset = intval( $_POST['offset'] );

// Floats (no native WP function — combine is_numeric() validation with floatval())
$price = floatval( $_POST['price'] );

HTML content: wp_kses and wp_kses_post

When the field legitimately stores HTML — a rich-text editor, a description that allows bold and links — you cannot strip all tags. Use wp_kses_post() for post-body-equivalent content (allows headings, paragraphs, lists, tables, links) or wp_kses() with a custom allowlist when you need stricter control:

// For content equivalent to post_content (most permissive safe allowlist)
$body = wp_kses_post( $_POST['custom_body'] );

// For a field that should only allow bold, italic, and links — nothing else
$teaser = wp_kses(
    $_POST['teaser'],
    array(
        'strong' => array(),
        'em'     => array(),
        'a'      => array( 'href' => array(), 'title' => array() ),
    )
);

Step 3: Escaping — Encoding Data at the Point of Output

Sanitization makes data safe for the database. Escaping makes data safe for the current rendering context. These are separate concerns: you should escape even data you previously sanitized, because the rules differ between a database column, an HTML attribute, a URL href, and a JavaScript string.

The cardinal rule: escape late, at the exact point of output. Never store escaped data in the database — store the clean value and escape only when printing.

Output context Function What it does
HTML body text esc_html() Converts <, >, &, ", ' to HTML entities
HTML attribute values esc_attr() Same as esc_html(); safe inside attribute="" quotes
URL in href/src esc_url() Strips dangerous protocols; encodes chars unsafe in a URL context
Inline JavaScript string esc_js() Escapes for safe embedding inside a JS string literal
Translated string with HTML wp_kses_post() Allows a safe subset of HTML in i18n strings that contain markup
$label = get_post_meta( $post_id, '_my_label', true );
$link  = get_post_meta( $post_id, '_my_url', true );
$class = get_post_meta( $post_id, '_my_class', true );

// In the HTML template:
?>
<div class="<?php echo esc_attr( $class ); ?>">
    <a href="<?php echo esc_url( $link ); ?>">
        <?php echo esc_html( $label ); ?>
    </a>
</div>
<?php

Parameterized Queries with $wpdb->prepare()

Sanitizing a value before inserting it into a raw SQL string is not enough protection against SQL injection — a well-crafted input can still escape your sanitization. The correct solution is parameterized queries via $wpdb->prepare(), which separates the SQL structure from the data entirely:

global $wpdb;

$user_id = absint( $_POST['user_id'] );
$status  = sanitize_key( $_POST['status'] );

// Safe: parameterized — $wpdb->prepare() handles escaping
$results = $wpdb->get_results(
    $wpdb->prepare(
        "SELECT * FROM {$wpdb->prefix}my_table WHERE user_id = %d AND status = %s",
        $user_id,
        $status
    )
);

// Dangerous — never do this, even after sanitize_text_field():
// $wpdb->query( "SELECT * FROM my_table WHERE status = '" . $status . "'" );

Use %d for integers, %s for strings, and %f for floats. The prepare call handles all quoting and escaping internally — never concatenate user input directly into an SQL string, even after running it through sanitize_text_field().

End-to-End Example: Meta Box Save Handler

Here is a complete save handler combining nonce verification, capability check, validation, sanitization, and safe storage:

function myplugin_save_meta( $post_id ) {
    // 1. Nonce check — abort immediately if missing or invalid
    if ( ! isset( $_POST['_myplugin_nonce'] ) ||
         ! wp_verify_nonce( $_POST['_myplugin_nonce'], 'myplugin_save_' . $post_id ) ) {
        return;
    }

    // 2. Capability check
    if ( ! current_user_can( 'edit_post', $post_id ) ) {
        return;
    }

    // 3. Skip auto-saves
    if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
        return;
    }

    // 4. Validate — reject bad shapes before sanitizing
    $email = $_POST['myplugin_email'] ?? '';
    if ( $email !== '' && ! is_email( $email ) ) {
        return; // Could also add_settings_error() here
    }

    $rating = $_POST['myplugin_rating'] ?? '';
    if ( $rating !== '' && ( ! is_numeric( $rating ) || (int) $rating < 1 || (int) $rating > 5 ) ) {
        return;
    }

    // 5. Sanitize — clean before storage
    $sanitized_email  = sanitize_email( $email );
    $sanitized_notes  = sanitize_textarea_field( $_POST['myplugin_notes'] ?? '' );
    $sanitized_rating = absint( $rating );

    // 6. Store
    update_post_meta( $post_id, '_myplugin_email',  $sanitized_email );
    update_post_meta( $post_id, '_myplugin_notes',  $sanitized_notes );
    update_post_meta( $post_id, '_myplugin_rating', $sanitized_rating );
}
add_action( 'save_post', 'myplugin_save_meta' );

And the corresponding output template, escaping at the point of print:

$email  = get_post_meta( $post_id, '_myplugin_email', true );
$notes  = get_post_meta( $post_id, '_myplugin_notes', true );
$rating = get_post_meta( $post_id, '_myplugin_rating', true );
?>
<p>Email: <?php echo esc_html( $email ); ?></p>
<p>Notes: <?php echo nl2br( esc_html( $notes ) ); ?></p>
<p>Rating: <?php echo absint( $rating ); ?>/5</p>
<?php

Validation and Sanitization in the REST API

REST API parameters declared via register_rest_route() accept inline validate_callback and sanitize_callback keys per argument. WordPress runs them automatically before your endpoint callback executes — you do not need to repeat the checks inside the callback:

register_rest_route( 'myplugin/v1', '/order', array(
    'methods'             => WP_REST_Server::CREATABLE,
    'callback'            => 'myplugin_handle_order',
    'permission_callback' => function() {
        return current_user_can( 'edit_posts' );
    },
    'args' => array(
        'email' => array(
            'required'          => true,
            'validate_callback' => function( $value ) {
                return is_email( $value );
            },
            'sanitize_callback' => 'sanitize_email',
        ),
        'quantity' => array(
            'required'          => true,
            'type'              => 'integer',
            'minimum'           => 1,
            'maximum'           => 100,
            'sanitize_callback' => 'absint',
        ),
    ),
) );

For full detail on building secure REST endpoints — including permission callbacks and schema-validated responses — see the guide on WordPress custom REST API endpoints.

Checklist: Secure Input Handling in WordPress

  1. Verify the nonce before reading any $_POST or $_GET field.
  2. Confirm the current user has the correct capability for the operation.
  3. Validate format and range; return or wp_die() immediately on failure — do not try to fix bad input.
  4. Sanitize with the function that matches storage context, not input type.
  5. Use absint() for every ID and count — it guarantees a non-negative integer.
  6. Use wp_kses_post() or wp_kses() only when HTML is genuinely needed; use sanitize_text_field() otherwise.
  7. Use $wpdb->prepare() for all hand-written SQL — never concatenate user input into a query string.
  8. Escape at the point of output with the context-appropriate function: esc_html(), esc_attr(), esc_url().
  9. Never store escaped data in the database — escape only at render time.
  10. In the REST API, declare validate_callback and sanitize_callback per argument so WordPress enforces them before your callback runs.

Input handling is one layer of a complete security posture. For the broader picture — file permissions, authentication hardening, and plugin vetting — see essential WordPress security practices and the proactive steps covered in proactive WordPress security measures.

Building a plugin or theme with complex forms or REST endpoints that you want security-reviewed? Get in touch — I audit input handling, escaping, and query hygiene as part of WordPress security consulting engagements.

Frequently asked questions

Validation checks whether input has the right shape — is this a valid email? Is this integer between 1 and 100? — and returns true or false. If validation fails, you reject the input entirely. Sanitization assumes the input is structurally acceptable and cleans it: stripping dangerous characters, removing HTML tags, casting to the correct type. The two always run in sequence: validate first to confirm the input is worth processing, then sanitize before writing to the database. Mixing up the order or skipping a step is a common source of WordPress vulnerabilities.

Use sanitize_text_field() whenever the stored value should be plain text — names, labels, titles, short descriptions. It strips all HTML tags. Use wp_kses_post() only when the field legitimately stores HTML markup, such as a rich-text description area where the user needs bold, links, or lists. wp_kses_post() applies WordPress's post-content allowlist, keeping common structural tags and stripping everything else. For even tighter control — say, only allowing <strong> and <em> — call wp_kses() directly with a custom allowlist array. When in doubt, default to sanitize_text_field().

Sanitization prepares data for the database; escaping prepares data for a specific rendering context. A value safely stored in the database can still cause an XSS vulnerability if it is printed into HTML without escaping, because the browser interprets unescaped angle brackets and quotes as markup. Escaping converts those characters into HTML entities at the exact point of output. The key rule is to escape late — at the echo statement, not earlier — and to use the function that matches the context: esc_html() for text inside tags, esc_attr() for attribute values, esc_url() for URLs in href/src attributes. Never store escaped data in the database; store the raw sanitized value and escape it fresh each time you render it.

Yes. Validation and sanitization answer different questions. Validation confirms the input has the right shape; it does not make the value safe to store. A string that passes is_email() still contains characters (angle brackets, quotes) that are dangerous in an HTML context if stored and later printed unsanitized. Similarly, a numeric string that passes is_numeric() should still be cast with absint() or intval() before storage to guarantee the type. Always run both stages: validate to accept or reject, then sanitize the accepted value before writing it anywhere.

When registering a REST route with register_rest_route(), each argument in the args array accepts a validate_callback and a sanitize_callback. WordPress runs these automatically before your endpoint callback executes — you do not need to repeat the checks inside the callback. The validate_callback receives the raw value and should return true or a WP_Error; the sanitize_callback receives the validated value and should return the cleaned version. You can also use the JSON Schema-style type, minimum, and maximum keys on integer arguments and WordPress will enforce them natively.

A nonce (Number Used Once) is a one-time token tied to a specific action and time window that WordPress generates with wp_create_nonce() and embeds in forms or AJAX requests. Verifying it with wp_verify_nonce() before reading $_POST confirms that the request originated from a form your code generated, not from an attacker's page. This prevents CSRF (Cross-Site Request Forgery) attacks, where a malicious third-party page tricks an authenticated user's browser into making a state-changing request to your site. Nonce verification must always be the first check in any save handler, AJAX callback, or form processor — before capability checks, before reading field values, before anything else.

Photo of Ajay Khandal

Written by Ajay Khandal

I'm a freelance WordPress developer with 14+ years of experience building, fixing, and speeding up sites for businesses, agencies, and store owners across the US, UK, Europe, and Australia. I specialize in custom themes, WooCommerce, and performance — the kind of work that shows up as faster load times and fewer support tickets. No account managers, no outsourced tickets — you work directly with me, with replies typically inside 5 hours.

Work with me →