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 |
|---|---|---|
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, so 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
- Verify the nonce before reading any
$_POSTor$_GETfield. - Confirm the current user has the correct capability for the operation.
- Validate format and range; return or
wp_die()immediately on failure; do not try to fix bad input. - Sanitize with the function that matches storage context, not input type.
- Use
absint()for every ID and count; it guarantees a non-negative integer. - Use
wp_kses_post()orwp_kses()only when HTML is genuinely needed; usesanitize_text_field()otherwise. - Use
$wpdb->prepare()for all hand-written SQL; never concatenate user input into a query string. - Escape at the point of output with the context-appropriate function:
esc_html(),esc_attr(),esc_url(). - Never store escaped data in the database; escape only at render time.
- In the REST API, declare
validate_callbackandsanitize_callbackper 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.


