Security, Validation, Sanitization, and Escaping
Security Boundary Model
| Boundary | Action |
|---|---|
| Input enters PHP | Validate and sanitize |
| User attempts action | Check capability |
| Browser submits state change | Verify nonce |
| Data enters SQL | Use prepared statements |
| Data leaves PHP | Escape by output context |
| File is uploaded | Validate permission, type, size, and name |
| Remote data arrives | Validate shape and sanitize fields |
Validation vs Sanitization vs Escaping
| Step | Question | Example |
|---|---|---|
| Validate | Is this acceptable? | Allowed layout is grid or list |
| Sanitize | How do we clean it? | sanitize_text_field() |
| Escape | How do we print it safely? | esc_html() |
Untrusted Sources
$_GET$_POST$_REQUEST$_COOKIE- uploaded files
- REST parameters
- AJAX payloads
- remote API responses
- imported CSV or JSON
- saved options from older code
- custom fields edited by users
WordPress Unslash
$raw = isset($_POST['title']) ? wp_unslash($_POST['title']) : '';
$title = sanitize_text_field($raw);
Sanitizers
| Data | Function |
|---|---|
| Plain text | sanitize_text_field() |
| Multiline text | sanitize_textarea_field() |
| Key | sanitize_key() |
| Slug | sanitize_title() |
sanitize_email() | |
| URL for storage | esc_url_raw() |
| File name | sanitize_file_name() |
| HTML class | sanitize_html_class() |
| Integer | absint() |
Escapers
| Output Context | Function |
|---|---|
| HTML text | esc_html() |
| HTML attribute | esc_attr() |
| URL | esc_url() |
| JavaScript data | wp_json_encode() |
| Textarea | esc_textarea() |
| Allowed post HTML | wp_kses_post() |
Capability Checks
if (! current_user_can('manage_options')) {
wp_die(esc_html__('Unauthorized.', 'my-plugin'));
}
Object-specific checks:
if (! current_user_can('edit_post', $post_id)) {
return;
}
Nonces
wp_nonce_field('myplugin_save', 'myplugin_nonce');
check_admin_referer('myplugin_save', 'myplugin_nonce');
Manual verification:
if (
! isset($_POST['myplugin_nonce']) ||
! wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['myplugin_nonce'])), 'myplugin_save')
) {
wp_die(esc_html__('Invalid request.', 'my-plugin'));
}
Nonces are not permission checks. Use capabilities too.
SQL Safety
global $wpdb;
$row = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM {$wpdb->users} WHERE ID = %d",
$user_id
)
);
Never concatenate untrusted values into SQL.
Redirect Safety
wp_safe_redirect(admin_url('options-general.php?page=my-plugin'));
exit;
REST Permission Callback
'permission_callback' => function (): bool {
return current_user_can('edit_posts');
},
Do not use __return_true for private data or write operations.
AJAX Security
function myplugin_ajax_save(): void {
check_ajax_referer('myplugin_ajax', 'nonce');
if (! current_user_can('edit_posts')) {
wp_send_json_error(['message' => __('Unauthorized.', 'my-plugin')], 403);
}
wp_send_json_success();
}
Common Vulnerabilities
| Vulnerability | Cause | Prevention |
|---|---|---|
| XSS | Raw output | Escape output |
| SQL injection | Raw SQL values | Prepared statements |
| CSRF | Missing nonce | Verify nonce for state changes |
| Privilege escalation | Missing capability check | Check exact capability |
| File upload abuse | Weak upload validation | Restrict type, size, permission |
| Open redirect | Trusting redirect URL | Use wp_safe_redirect() |
| SSRF | Fetching arbitrary URLs | Allow-list hosts or validate URLs |
Production Checklist
- Every request handler starts with capability and nonce/auth checks.
- Every superglobal read uses
wp_unslash()then sanitization. - Every SQL query with variable data uses
$wpdb->prepare(). - Every template output uses the correct escape function.
- Every REST route has a real permission callback.
- Every upload has permission, type, size, and destination checks.
- Logs do not contain secrets.