WordPress Input Sanitization in 2026: Functions, Patterns, and the Mistakes That Break Security

How to Use WordPress Functions for Input Sanitization

WordPress Input Sanitization in 2026: Functions, Patterns, and the Mistakes That Break Security

Input sanitization is one of those WordPress security topics where the function names are widely-known but the patterns around them aren’t. Developers know sanitize_text_field() exists. Fewer remember when to combine it with wp_unslash(). Even fewer can articulate the difference between sanitization, validation, and escaping โ€” three jobs that get confused because they all involve “cleaning data” in different contexts.

This guide covers the WordPress sanitization functions you actually use, the decision framework for when each one applies, and the patterns that prevent the security mistakes that cause the most real-world WordPress vulnerabilities. The technical sections include working code; the framing sections cover the decisions about what code to write in the first place.

Where this code actually lives in your codebase

Before getting into functions, a quick orientation note. Input sanitization code lives in specific places depending on what kind of input you’re handling:

  • Form submissions via admin-post or admin-ajax actions โ†’ in your handler function hooked to admin_post_{action} / wp_ajax_{action} / wp_ajax_nopriv_{action}, typically in your plugin’s main file or a dedicated handlers file.
  • REST API endpoints โ†’ in the sanitize_callback you declare in register_rest_route(), or inside your endpoint callback function.
  • Settings API โ†’ in the sanitize_callback you pass to register_setting().
  • Block attributes (Gutenberg) โ†’ in your block.json attribute schema (declarative) or in the render_callback for dynamic blocks.
  • Direct $_POST reads in template or theme code โ†’ wherever you read the superglobal (forms in widgets, custom field handlers, etc.). Generally a pattern to avoid in modern WordPress in favor of the structured APIs above, but you’ll still encounter it in legacy code.

If you’re not sure where to put your sanitization code, the underlying question is “what’s actually receiving this input?” โ€” that’s the function that needs to sanitize.

Why XSS matters (and where sanitization fits)

Cross-site scripting (XSS) is when an attacker gets their own JavaScript to execute in someone else’s browser session on your site. The most common pattern: attacker submits malicious HTML/JS through a form field, your site stores it without proper handling, and later renders it back to other users โ€” whose browsers execute the attacker’s script with those users’ privileges.

Stored XSS lives in your database โ€” submitted once, served to every subsequent visitor of the affected page. Reflected XSS lives in URLs or request data โ€” only affects users who visit a crafted link. Both are prevented by the same three-job approach: validate that input looks like what you expect, sanitize to clean for storage, and escape at output for the specific rendering context.

Sanitization without output escaping is the most common cause of stored XSS in WordPress plugins. The data went into the database “clean” but came out into HTML without context-appropriate escaping, and a <script> tag that survived sanitization (or got reconstructed from sanitized fragments) executes in the visitor’s browser. Both layers are needed; neither is sufficient alone.

Sanitize vs validate vs escape โ€” three jobs, three places

The three terms get used interchangeably in WordPress documentation and tutorials, but they describe distinct jobs at distinct points in the data lifecycle. Getting them confused is the most common source of security bugs in WordPress code.

Validate โ€” Does the input match what you expected? Validation answers a yes/no question. Is this string a valid email address? Is this number between 1 and 100? Does this enum match one of the allowed values? Validation rejects invalid input rather than transforming it.

Sanitize โ€” Make the input safe to store. Sanitization transforms input data into a form that can be safely persisted (to the database, to options, to user meta). It removes characters that could cause problems downstream, normalizes formatting, and produces data your code can rely on. The original input is replaced by the sanitized version.

Escape โ€” Make the stored data safe to output. Escaping happens at output time, in the specific context where the data will be rendered. Text in HTML attributes escapes differently from text in JavaScript blocks, which escapes differently from text in URLs. The same stored value needs different escaping depending on where it appears.

The three jobs happen at different points in the request lifecycle:

  1. Input arrives (form POST, query string, REST request, file upload) โ†’ Validate that it looks like what you asked for. Sanitize to clean it for storage.
  2. Data is stored in the database or options table.
  3. Data is output (rendered in HTML, included in URLs, used in JavaScript) โ†’ Escape for the specific output context.

Getting this sequence wrong is the source of XSS vulnerabilities, SQL injection, broken authentication, and the long tail of “we sanitized the input, why does the site still have a security issue” tickets. Sanitization without escaping leaves output vulnerabilities. Escaping without sanitization leaves storage problems. Both without validation leaves logic bugs.

The core WordPress sanitization functions, organized by input type

WordPress ships with dozens of sanitization helpers. Memorizing all of them is the wrong goal โ€” what matters is knowing which one to reach for given the input type. Below the functions are grouped by what they’re designed to handle.

Text input โ€” sanitize_text_field() and friends

sanitize_text_field() is the default for short single-line text inputs (names, subjects, search terms, simple labels). It strips HTML tags, removes line breaks and extra whitespace, and produces a clean string. Use it whenever you have generic short text and don’t want HTML, code, or formatting to pass through.

$clean_name = sanitize_text_field( wp_unslash( $_POST['name'] ?? '' ) );

The wp_unslash() call before sanitize_text_field() is critical when reading from $_POST, $_GET, $_COOKIE, or $_REQUEST. WordPress automatically adds slashes to these superglobals (a legacy from the magic_quotes_gpc era), and sanitize_text_field() does not remove them on its own. Skipping wp_unslash() results in O\'Brien being stored instead of O'Brien.

sanitize_textarea_field() is the multi-line version. Use it for textarea inputs where you want to preserve line breaks but strip HTML and dangerous characters.

$clean_message = sanitize_textarea_field( wp_unslash( $_POST['message'] ?? '' ) );

Identifiers โ€” slugs, keys, usernames, class names

WordPress separates “identifier-shaped” strings into specialized functions because each has different allowed characters and normalization rules.

sanitize_key() is for internal identifiers โ€” option names, meta keys, transient keys. It lowercases the input and restricts characters to letters, numbers, hyphens, and underscores. Use it whenever you’re constructing a key from user input.

$option_key = 'my_plugin_' . sanitize_key( $_POST['key'] );

sanitize_title() produces URL-safe slugs from input strings. It transliterates accented characters, replaces spaces with hyphens, and strips disallowed characters. Use it when generating post slugs, taxonomy terms, or URL fragments.

sanitize_user() cleans WordPress username input. Allowed characters depend on the strict parameter โ€” true matches WordPress’s actual username constraints; false allows broader Unicode for display purposes.

sanitize_html_class() produces safe CSS class names. Allowed characters are letters, numbers, underscores, and hyphens; anything else is stripped. Use it when generating dynamic class names from input.

Numbers โ€” absint(), intval(), floatval()

absint() returns a non-negative integer. It’s the standard for ID-like fields where negative values make no sense (post IDs, user IDs, term IDs, comment IDs).

$post_id = absint( $_GET['post_id'] ?? 0 );

intval() returns a signed integer. Use it when negative values are valid (offsets, signed counters, ordering).

floatval() parses a float. Use it for decimal numbers (prices, percentages, scores). Pair it with number_format() or round() if you need specific precision.

Numbers don’t need wp_unslash() because the cast itself ignores backslashes. The sanitization happens implicitly via the type cast.

URLs โ€” sanitize_url() and esc_url_raw()

sanitize_url() and esc_url_raw() are the same function (the former is an alias added in WordPress 5.9). Both clean URL input for storage โ€” verifying the URL is reasonably-formed and stripping disallowed characters and protocols.

$clean_url = sanitize_url( $_POST['website'] ?? '' );

Note the distinction: esc_url() (without _raw) is for output, not input. Use esc_url() when rendering a URL in HTML; use sanitize_url() / esc_url_raw() when storing one.

Emails โ€” sanitize_email() and is_email()

sanitize_email() strips characters that aren’t valid in email addresses. It produces a string that’s probably an email, but doesn’t strictly validate it. Pair with is_email() for actual validation:

$email = sanitize_email( $_POST['email'] ?? '' );
if ( ! is_email( $email ) ) {
    wp_die( 'Invalid email address' );
}

sanitize_email() is sanitization; is_email() is validation. They serve different jobs โ€” the first cleans the input, the second tells you whether the cleaned input is usable.

HTML โ€” wp_kses() and its variants

When you do want to allow some HTML through (rich text fields, comment-like content, restricted markdown-style markup), use the wp_kses family. Raw HTML passing through sanitize_text_field() gets stripped entirely; raw HTML passing through database storage without sanitization is an XSS vulnerability waiting to happen.

wp_kses() takes an allowed-tags array and strips everything not on the list:

$allowed_tags = [
    'a'      => [ 'href' => [], 'title' => [] ],
    'strong' => [],
    'em'     => [],
    'p'      => [],
];
$safe_html = wp_kses( wp_unslash( $_POST['content'] ?? '' ), $allowed_tags );

wp_kses_post() uses the same allowlist that WordPress applies to post content โ€” appropriate for content fields that should accept the same HTML as a published post.

wp_kses_data() uses a more restrictive default for comment-like content. Reach for it when you want to allow basic formatting but not arbitrary post-content HTML.

Files โ€” sanitize_file_name() plus MIME validation

File uploads need both validation and sanitization at multiple layers. sanitize_file_name() cleans the filename string, but it doesn’t tell you anything about what’s actually in the file. A user uploading evil.php renamed to cute_cat.jpg will pass filename sanitization easily โ€” your protection has to come from MIME and extension checks plus storage configuration.

The full file upload flow:

// 1. Check the file array exists
if ( empty( $_FILES['upload']['name'] ) ) {
    wp_die( 'No file uploaded' );
}

// 2. Sanitize the filename
$filename = sanitize_file_name( $_FILES['upload']['name'] );

// 3. Validate MIME and extension via WordPress's own check
$file_type = wp_check_filetype_and_ext(
    $_FILES['upload']['tmp_name'],
    $filename
);

if ( ! $file_type['type'] ) {
    wp_die( 'File type not allowed' );
}

// 4. Restrict to your allowed list
$allowed_types = [ 'image/jpeg', 'image/png', 'application/pdf' ];
if ( ! in_array( $file_type['type'], $allowed_types, true ) ) {
    wp_die( 'File type not in allowed list' );
}

// 5. Size check (10MB example)
if ( $_FILES['upload']['size'] > 10 * 1024 * 1024 ) {
    wp_die( 'File too large' );
}

// 6. Use wp_handle_upload() rather than moving the file manually โ€”
//    it handles uploads directory, conflict resolution, and security
$movefile = wp_handle_upload( $_FILES['upload'], [ 'test_form' => false ] );

if ( isset( $movefile['error'] ) ) {
    wp_die( $movefile['error'] );
}

wp_check_filetype_and_ext() checks both the extension and the actual file content (where supported) โ€” significantly stronger than trusting the filename alone. wp_handle_upload() handles the secure file move into wp-content/uploads/ with proper conflict resolution.

Server-side execution prevention is the other half of safe uploads. Your uploads directory should be configured so PHP files cannot execute from it even if they get uploaded. The standard pattern is a .htaccess rule (Apache) or equivalent nginx config that blocks PHP execution in wp-content/uploads/. Many security plugins (Wordfence, Solid Security, Sucuri) add or maintain this configuration. Without it, a file that slips through your sanitization checks can still be executed by a direct request to its URL.

Meta values โ€” sanitize_meta() and registered sanitizers

When you store data as post meta, user meta, or term meta, you can register a sanitization callback that WordPress applies automatically every time the value is set. This is cleaner than sanitizing manually at every save site.

register_post_meta( 'post', 'priority', [
    'type'              => 'integer',
    'single'            => true,
    'show_in_rest'      => true,
    'sanitize_callback' => 'absint',
    'auth_callback'     => function() {
        return current_user_can( 'edit_posts' );
    },
] );

After registration, any code that calls update_post_meta( $post_id, 'priority', $value ) runs absint() on the value before storage. For meta exposed to the REST API via show_in_rest, the registered sanitizer is also applied to incoming REST requests.

register_term_meta() and register_user_meta() work the same way for their respective object types.

Options โ€” register_setting() sanitizers

The Settings API also handles sanitization via callback registration. Covered in the “Sanitization in WordPress settings” section below, but worth noting alongside meta sanitization โ€” both use the “register once, sanitize everywhere” pattern.

Nonces and capability checks โ€” security before sanitization

Sanitization makes input safe to store. It does nothing about whether the user submitting the input is allowed to take this action, or whether the request itself is legitimate. Two WordPress mechanisms handle those questions and need to run before (or alongside) your sanitization code.

Nonces โ€” verifying the request is legitimate

A nonce is a one-time-use token WordPress generates for a specific action and a specific user, embedded in your form or AJAX request. Verifying it on the receiving end confirms the request came from your form (not a forged request from another site) and was made by the user who loaded the form.

The pattern for form handlers:

// In the form output
wp_nonce_field( 'myplugin_save_item', 'myplugin_nonce' );

// In the handler that processes the submission
if ( ! isset( $_POST['myplugin_nonce'] )
     || ! wp_verify_nonce( $_POST['myplugin_nonce'], 'myplugin_save_item' ) ) {
    wp_die( 'Security check failed' );
}

// Now safe to read and sanitize the rest of the input
$title = sanitize_text_field( wp_unslash( $_POST['title'] ?? '' ) );

For AJAX requests, check_ajax_referer() handles the verification:

add_action( 'wp_ajax_myplugin_action', 'myplugin_ajax_handler' );
function myplugin_ajax_handler() {
    check_ajax_referer( 'myplugin_ajax_action', 'security' );
    // ... sanitize and process
}

REST API endpoints don’t typically use the nonce pattern โ€” they have their own authentication (Application Passwords, cookies + nonce header, OAuth, JWT). The permission_callback in register_rest_route() handles the equivalent check.

Capability checks โ€” verifying the user can take this action

Even with a valid nonce, you need to confirm the current user has permission to do what they’re asking. WordPress’s capability system (current_user_can()) is the standard:

if ( ! current_user_can( 'edit_posts' ) ) {
    wp_die( 'You are not allowed to do this' );
}

For object-specific permissions (e.g., editing a specific post), pass the object ID:

$post_id = absint( $_POST['post_id'] ?? 0 );
if ( ! current_user_can( 'edit_post', $post_id ) ) {
    wp_die( 'You are not allowed to edit this post' );
}

The order is always the same: verify nonce โ†’ verify capability โ†’ sanitize inputs โ†’ process. Skipping any of these layers creates a vulnerability the others can’t compensate for.

The wp_unslash() gotcha โ€” when to use it and why

wp_unslash() is the WordPress source of more confusion than any other sanitization function. The rule is simple but unintuitive: whenever you read from $_POST, $_GET, $_COOKIE, or $_REQUEST, call wp_unslash() before any other sanitization.

The reason traces to WordPress’s history with PHP’s magic_quotes_gpc setting. WordPress adds slashes to superglobal data for backwards compatibility, regardless of PHP’s actual magic_quotes setting (deprecated since PHP 5.4). The slashes need to be removed before sanitization, or your sanitized output will contain unwanted backslashes.

// Wrong โ€” slashes pass through into stored data
$name = sanitize_text_field( $_POST['name'] );
// Result: O\'Brien gets stored as O\'Brien

// Right โ€” slashes removed first
$name = sanitize_text_field( wp_unslash( $_POST['name'] ) );
// Result: O\'Brien gets stored as O'Brien

wp_unslash() is safe to call on data that doesn’t have slashes โ€” it’s a no-op in that case. Calling it unnecessarily costs nothing; forgetting it produces bugs that surface as user complaints about names with backslashes weeks after deploy.

When you don’t need wp_unslash():

  • When reading from numeric type casts (absint(), intval(), floatval() consume slashes implicitly via the cast)
  • When the data wasn’t from a superglobal in the first place (e.g., already-sanitized database content, hardcoded strings, generated values)
  • When using WordPress functions that handle unslashing internally (some REST API parameter callbacks)

When you need it (the common cases):

  • Any $_POST, $_GET, $_COOKIE, or $_REQUEST read
  • Any $_FILES array values (filename, type)
  • Any cookie data read directly

When in doubt, add wp_unslash(). The downside of using it where unnecessary is zero; the downside of skipping it where needed is corrupted data.

Sanitization in REST API endpoints

Custom REST API endpoints in WordPress get a clean sanitization model through register_rest_route() and the args array. Each parameter can have a sanitize_callback and a validate_callback declared up front, and WordPress runs them before your endpoint handler executes.

register_rest_route( 'myplugin/v1', '/items', [
    'methods'  => 'POST',
    'callback' => 'myplugin_create_item',
    'permission_callback' => function() {
        return current_user_can( 'edit_posts' );
    },
    'args' => [
        'title' => [
            'required'          => true,
            'sanitize_callback' => 'sanitize_text_field',
            'validate_callback' => function( $value ) {
                return is_string( $value ) && strlen( $value ) >= 3;
            },
        ],
        'count' => [
            'required'          => false,
            'default'           => 1,
            'sanitize_callback' => 'absint',
            'validate_callback' => function( $value ) {
                return $value >= 1 && $value <= 100;
            },
        ],
    ],
] );

Note that REST endpoints handled this way don’t need wp_unslash() calls โ€” the REST API handles unslashing internally before passing parameters to your callbacks. The simplicity of the args declaration is part of why custom REST endpoints are usually safer than ad-hoc admin-ajax handlers.

For complex sanitization (nested arrays, conditional rules, cross-field validation), you can pass a closure or a named function reference as the callback:

'tags' => [
    'sanitize_callback' => function( $value ) {
        if ( ! is_array( $value ) ) {
            return [];
        }
        return array_map( 'sanitize_text_field', array_map( 'wp_unslash', $value ) );
    },
],

Sanitization in WordPress settings (register_setting)

The Settings API has the same callback pattern. When you register a setting, supply a sanitize_callback:

register_setting( 'myplugin_options', 'myplugin_settings', [
    'type'              => 'array',
    'sanitize_callback' => 'myplugin_sanitize_settings',
    'default'           => [],
] );

function myplugin_sanitize_settings( $input ) {
    $sanitized = [];
    $sanitized['name']    = sanitize_text_field( $input['name'] ?? '' );
    $sanitized['url']     = sanitize_url( $input['url'] ?? '' );
    $sanitized['count']   = absint( $input['count'] ?? 0 );
    $sanitized['enabled'] = ! empty( $input['enabled'] );
    return $sanitized;
}

The Settings API also handles unslashing internally โ€” your callback receives the already-unslashed data. This makes settings sanitization slightly cleaner than direct superglobal reads.

Output escaping by context โ€” the other half of the security model

Sanitization handles input. Escaping handles output. WordPress has dedicated escape functions for each rendering context, and using the wrong one can leave you with the same XSS vulnerability you tried to prevent by sanitizing in the first place.

esc_html() โ€” HTML body content

For data rendered inside the body of an HTML element (paragraph text, headings, list items, etc.), esc_html() converts characters like <, >, &, ', and " to their HTML entities. The output is safe to render as HTML content but won’t display intended HTML markup โ€” angle brackets become visible characters rather than tags.

echo '<p>' . esc_html( $user_input ) . '</p>';

esc_attr() โ€” HTML attribute values

For data rendered inside an HTML attribute (like value="...", class="...", data-*="..."), esc_attr() is the right escape function. It encodes quotes and other characters that could break out of the attribute context.

echo '<input type="text" value="' . esc_attr( $user_input ) . '">';

Using esc_html() for attribute values isn’t strictly broken but doesn’t fully cover the attribute-escape requirements. Use esc_attr() for any attribute.

esc_url() โ€” URLs in href and src

For URLs rendered into href, src, action, or similar URL-bearing attributes, esc_url() performs URL-specific escaping plus protocol verification.

echo '<a href="' . esc_url( $user_link ) . '">Link</a>';

esc_url() (for output) is different from sanitize_url() / esc_url_raw() (for storage). The output version is more aggressive about encoding characters that might break HTML attributes; the storage version preserves more of the URL structure.

esc_js() โ€” JavaScript context

For data being rendered inside a JavaScript block or inline JS handler, esc_js() escapes characters that could break out of a JavaScript string literal.

echo '<script>var msg = "' . esc_js( $user_message ) . '";</script>';

Note: rendering user-supplied data into inline JavaScript is generally a pattern to avoid. Where possible, pass data to JavaScript via wp_localize_script() or wp_add_inline_script(), which handles the encoding correctly via JSON.

esc_textarea() โ€” textarea body content

For data rendered inside a <textarea> element, esc_textarea() handles the slightly different escaping requirements of textarea content (which preserves newlines unlike esc_html()).

echo '<textarea name="content">' . esc_textarea( $stored_content ) . '</textarea>';

wp_kses_post() and wp_kses() โ€” allowing some HTML through

When the data is intentionally HTML (rich text content from a WYSIWYG editor, for instance), use wp_kses_post() for post-content-level allowed tags or wp_kses() with a custom allowlist. These are escape-style functions in the output direction even though they share function names with the sanitization variants.

The matching rule

The escape function should match the context where the data is rendered. The same stored string needs different escaping if it appears in HTML body vs an attribute vs a URL vs JavaScript. There’s no single “safe” escape function โ€” context determines the right choice.

Common sanitization mistakes

These are the patterns that show up in real-world WordPress security audits, in order of how often they cause actual incidents.

Sanitizing for output instead of input. Using esc_html() or esc_attr() when reading data from a form, instead of sanitize_text_field(). The escape functions are designed for output context โ€” they don’t strip dangerous characters, they encode them for safe display. Storing escape-encoded data leads to double-encoding when you later escape it for output again.

Using only sanitization without escaping at output. Sanitizing input cleans the storage, but data still needs to be escaped at output. The same stored string needs esc_html() when rendered in HTML, esc_attr() when rendered in an HTML attribute, esc_url() when rendered as a URL, and so on. Skipping output escaping is the cause of most stored XSS vulnerabilities in WordPress plugins.

Forgetting wp_unslash() on $_POST / $_GET reads. Discussed above. The visible symptom is data with stray backslashes; the invisible symptom is sanitization that doesn’t quite work as expected.

Using sanitize_text_field() on multi-line content. It strips line breaks. Use sanitize_textarea_field() for textareas, or you’ll lose paragraph structure.

Trusting sanitize_email() as validation. It cleans the input but doesn’t validate it. Pair it with is_email() if you need to know whether the result is actually a usable email.

Sanitizing arrays element-by-element manually. When sanitizing an array of values, use array_map() with the appropriate callback rather than iterating. It’s cleaner and easier to read:

$clean_tags = array_map( 'sanitize_text_field', array_map( 'wp_unslash', (array) $tags ) );

Sanitizing once, then forgetting after a database round-trip. Some developers sanitize on input, store the data, and assume the stored data is “clean” for any future use. Stored data still needs context-appropriate escaping at output. Sanitization at input is necessary but not sufficient.

Using stripslashes() instead of wp_unslash(). The two are functionally similar but wp_unslash() is the WordPress-idiomatic choice that handles arrays and nested data correctly. Use wp_unslash() consistently.

Skipping sanitization on “trusted” admin input. Admin users can have their accounts compromised. Sanitize admin input the same way you sanitize public input โ€” the security model should not depend on the user role.

Confusing sanitize_url() with esc_url(). sanitize_url() (alias esc_url_raw()) is for storage. esc_url() is for output. They produce slightly different results, and using the output version for storage leads to double-encoded URLs.

Validation vs sanitization โ€” a decision matrix

When does each apply? The answer is usually “both, in sequence” โ€” validate first to reject malformed input, then sanitize what passes through. Below are the common cases.

You have a fixed set of allowed values (enum). Validate by checking membership in the allowed set. Sanitization isn’t enough โ€” sanitize_text_field() will happily produce a cleaned-up version of pwned even though only published, draft, and pending were valid.

$status = sanitize_key( $_POST['status'] ?? '' );
if ( ! in_array( $status, [ 'published', 'draft', 'pending' ], true ) ) {
    wp_die( 'Invalid status' );
}

The input must match a specific format. Validate format first (email, URL, phone), then sanitize. The pair gives both “is this the right shape” and “is it safe to store.”

The input is free-form text. Sanitize without explicit validation; the cleaned text is the validated text. This is the case where sanitize_text_field() or wp_kses() does the whole job.

The input is numeric within a range. Validate the range explicitly. absint() ensures non-negative integer, but doesn’t bound the maximum โ€” bound it yourself if needed.

The input is a free-form URL. sanitize_url() cleans the format. If you need to enforce a specific scheme (https only) or domain whitelist, add validation logic on top.

Frequently asked questions

Does sanitize_text_field() strip slashes?

No. sanitize_text_field() removes HTML tags and normalizes whitespace, but it doesn’t remove backslashes added by WordPress’s magic-quotes-style superglobal handling. Use wp_unslash() first when reading from $_POST / $_GET / $_COOKIE / $_REQUEST.

Why does WordPress add slashes to superglobals if magic_quotes_gpc was removed years ago?

Backwards compatibility with older plugins and themes that assume slashed superglobals. WordPress adds the slashes in its bootstrap to maintain that assumption regardless of PHP’s actual configuration. You then call wp_unslash() to undo it when you’re ready to work with the actual input.

When should I use wp_kses_post() vs wp_kses_data()?

wp_kses_post() allows the same HTML that’s permitted in post content (the broader allowlist, including more tags and attributes). wp_kses_data() is more restrictive โ€” appropriate for comment-like content where you want basic formatting but not arbitrary post HTML. Use wp_kses_post() for content that’s expected to be rich; wp_kses_data() for content where the safe default is restrictive.

Is esc_html() a sanitization function?

No โ€” esc_html() is an output-context escape function. It encodes characters for safe HTML display, but doesn’t remove anything from the input. Use it when rendering data to HTML output, not when reading input from a form.

Do I need wp_unslash() on REST API parameters?

No, in most cases. The REST API handles unslashing before passing parameters to your endpoint callbacks (provided you use register_rest_route() with the args declaration). For direct reads from $_POST inside an admin-ajax handler, you still need wp_unslash().

How do I sanitize an array of input values?

Use array_map() with the appropriate sanitizer callback. For example, to sanitize an array of tag names from $_POST:

$tags = array_map( 'sanitize_text_field', array_map( 'wp_unslash', (array) ( $_POST['tags'] ?? [] ) ) );

The (array) cast handles the case where the input isn’t actually an array, returning an empty array instead of breaking.

What about SQL injection โ€” does sanitization prevent it?

Sanitization helps but is not sufficient. The reliable defense against SQL injection in WordPress is $wpdb->prepare() with placeholders. Sanitized strings can still be unsafe in raw SQL if they’re concatenated rather than parameterized.

// Safe
$results = $wpdb->get_results( $wpdb->prepare(
    "SELECT * FROM {$wpdb->posts} WHERE post_title LIKE %s",
    '%' . $wpdb->esc_like( $search_term ) . '%'
) );

Where can I find the canonical sanitization function reference?

The WordPress Developer Reference has the authoritative documentation for each function โ€” sanitize_text_field(), sanitize_email(), wp_kses(), and the rest. The reference includes the actual function signature, parameter types, and return values. For day-to-day work, knowing which function to reach for matters more than memorizing the implementation.

Why does my HTML get stripped even after I called wp_kses()?

The most common cause: you called sanitize_text_field() somewhere earlier in the data pipeline (often when reading from $_POST), which stripped all HTML before wp_kses() ever ran. By the time wp_kses() sees the data, there’s nothing left to filter. Order matters โ€” if you want HTML through, skip sanitize_text_field() and use wp_kses() (with wp_unslash() first) directly on the original $_POST value.

How do I sanitize deeply nested form data (arrays inside arrays)?

For nested structures, write a recursive sanitizer or use map_deep():

$clean_data = map_deep( wp_unslash( $_POST['settings'] ?? [] ), 'sanitize_text_field' );

map_deep() applies the callback to every leaf value in the nested structure, regardless of depth. For mixed types (different sanitizers for different fields), write a recursive function with explicit per-field sanitization.

When should I not sanitize input?

Three cases:
– Reading already-sanitized data back from the database (the sanitization happened on the way in; reads don’t need it again, but output still needs escaping)
– Working with values you generated yourself (timestamps, internal IDs, controlled enum values that didn’t come from user input)
– Reading from REST API parameters where you declared a sanitize_callback in register_rest_route() โ€” WordPress already ran it before your endpoint code

Skipping sanitization on actual user input is rarely the right call. When in doubt, sanitize.

Do I need to worry about input sanitization for trusted admin users?

Yes. Admin accounts get compromised. Trusted internal users get phishing emails. The security model should not assume any user role is incapable of submitting malicious input โ€” sanitize the same way regardless of who is submitting. The cost is essentially zero; the cost of treating admin input as trusted is the next vulnerability disclosure your plugin appears in.

Does sanitization affect performance noticeably?

In normal usage, no โ€” the WordPress sanitization functions are fast (microseconds per call). The performance cases worth thinking about: sanitizing large arrays of values inside loops, or running expensive sanitizers (wp_kses() with a large allowlist) on high-throughput AJAX endpoints. For those cases, profile the actual cost rather than guessing. For typical form handling, sanitization cost is below noise.

What to do next

If you’re writing new code, the right starting point is the three-jobs framework โ€” validate the shape of the input, sanitize for storage, escape for output. Pick the sanitization function that matches the input type rather than reaching for sanitize_text_field() for everything.

If you’re auditing existing code, the patterns to grep for are:
$_POST, $_GET, $_COOKIE, $_REQUEST reads without wp_unslash() nearby
– Output rendering without esc_html(), esc_attr(), esc_url(), or similar context-appropriate escaping
esc_html() or esc_attr() used on input (wrong direction)
stripslashes() instead of wp_unslash() (legacy pattern, still works but not idiomatic)
– Direct SQL concatenation instead of $wpdb->prepare()

If you’re inheriting a codebase and unsure where to start, the highest-impact area is usually the form handlers and AJAX endpoints โ€” they’re where untrusted input enters, and where most sanitization mistakes accumulate. A focused audit of those entry points typically finds more real vulnerabilities than a top-to-bottom review of the entire codebase.

The WordPress sanitization functions are well-designed once you internalize the three-jobs model. The hard part isn’t knowing which function exists โ€” it’s knowing which job you’re doing at any given moment in the code, and reaching for the function that matches.

Database-Layer Safety with $wpdb

Sanitizing input on the way in and escaping on the way out covers most of your code, but the moment you write a custom database query you open a separate hole: SQL injection. Any value that touches a query string has to be parameterized, and WordPress gives you the tools to do it correctly through the global $wpdb object.

Parameterize every query with $wpdb->prepare()

Never concatenate a variable straight into SQL. $wpdb->prepare() substitutes your values into placeholders and escapes them for the SQL context, which neutralizes injection. The placeholder types are %s for strings, %d for integers, %f for floats, and %i for identifiers such as table or column names (WordPress 6.2 and later).

global $wpdb;

$user_id = absint( $_GET['user_id'] );

$results = $wpdb->get_results(
    $wpdb->prepare(
        "SELECT * FROM {$wpdb->prefix}user_settings WHERE user_id = %d",
        $user_id
    )
);

A common mistake is to wrap the placeholder in quotes yourself. Write WHERE name = %s, not WHERE name = '%s'. The prepare() call adds the quoting for you, and adding your own breaks the query.

Use the built-in CRUD methods with format specifiers

For straightforward inserts, updates, and deletes you do not even need to write SQL. The insert(), update(), and delete() methods take an array of data and a parallel array of format specifiers, and they escape everything for you.

global $wpdb;

$wpdb->insert(
    $wpdb->prefix . 'user_settings',
    array(
        'user_id'       => 42,
        'setting_name'  => 'theme_preference',
        'setting_value' => 'dark_mode',
    ),
    array( '%d', '%s', '%s' )
);

$wpdb->update(
    $wpdb->prefix . 'user_settings',
    array( 'setting_value' => 'light_mode' ), // data
    array( 'user_id' => 42 ),                 // where
    array( '%s' ),                            // data format
    array( '%d' )                             // where format
);

Escape LIKE wildcards with esc_like()

Search queries that use LIKE need extra care, because the % and _ characters in user input are wildcards. Pass the value through $wpdb->esc_like() first, then through prepare(), so a user searching for a literal percent sign does not accidentally match everything.

$term = '50% off';

$like = '%' . $wpdb->esc_like( $term ) . '%';

$rows = $wpdb->get_results(
    $wpdb->prepare(
        "SELECT * FROM {$wpdb->prefix}posts WHERE post_title LIKE %s",
        $like
    )
);

Build placeholders dynamically for WHERE IN clauses

An IN clause with a variable number of values trips up a lot of developers. You cannot pass an array to a single placeholder, so generate one placeholder per value, then unpack the array into prepare().

$ids = array( 12, 18, 25 );

$placeholders = implode( ', ', array_fill( 0, count( $ids ), '%d' ) );

$sql = "SELECT * FROM {$wpdb->prefix}posts WHERE ID IN ( $placeholders )";

$rows = $wpdb->get_results(
    $wpdb->prepare( $sql, $ids )
);

The standalone esc_sql() function exists for the rare case where you need to escape a value for a query you are assembling by hand, but reach for it last. prepare() and the CRUD methods are safer because they handle quoting and type-casting together, and they are far harder to misuse.

Need an independent review of your WordPress code? See how Osom Studio’s WordPress code audit works.


Discover more from WP Winners ๐Ÿ†

Subscribe to get the latest posts sent to your email.

More WorDPRESS Tips, tutorials and Guides

Discover more from WP Winners ๐Ÿ†

Subscribe now to keep reading and get access to the full archive.

Continue reading