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 — 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.


