
Most WordPress security problems don’t come from WordPress itself — they come from plugins. A form that saves data without checking who submitted it, an AJAX handler that trusts user input, an admin page that any logged-in user can access. These are the patterns that get sites compromised.
This post covers the four security mechanisms every plugin developer needs to use consistently: nonces, sanitization, escaping, and capability checks. Each one closes a specific attack surface. Skip any one of them and you leave a gap.
The first question your plugin should ask on any sensitive action is whether the current user is allowed to perform it. WordPress handles this with current_user_can(), which checks against the built-in role and capability system.
// Basic capability check before processing anything
function my_plugin_save_settings(): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( 'You do not have permission to do this.' );
}
// safe to proceed
}
PHPmanage_options is the capability tied to the Administrator role. Here are the ones you’ll use most often:
| Capability | Who has it | Use when |
|---|---|---|
manage_options | Administrator | Plugin settings, site-wide config |
edit_posts | Editor, Author, Contributor | Creating or editing content |
publish_posts | Editor, Author | Publishing content |
edit_others_posts | Editor | Editing other users’ content |
manage_categories | Editor, Administrator | Category/taxonomy management |
upload_files | Author and above | Media uploads |
Always check the minimum capability needed for the action — don’t default to manage_options for everything just because it’s the most restrictive. If an action is relevant to editors, check edit_posts instead.
For REST API endpoints and AJAX handlers, the same rule applies:
// In a REST API callback
add_action( 'rest_api_init', function() {
register_rest_route( 'myplugin/v1', '/settings', [
'methods' => 'POST',
'callback' => 'my_plugin_update_settings',
'permission_callback' => function() {
return current_user_can( 'manage_options' );
},
] );
} );
// In an AJAX handler — check capability before doing anything else
add_action( 'wp_ajax_my_plugin_action', function() {
if ( ! current_user_can( 'edit_posts' ) ) {
wp_send_json_error( 'Insufficient permissions', 403 );
}
// proceed
} );
PHPA capability check confirms who the user is. A nonce confirms that the request actually came from your form — not from a malicious third-party site that tricked the user into submitting something. This is how you prevent CSRF (Cross-Site Request Forgery) attacks.
The name “nonce” is slightly misleading in WordPress — unlike a true cryptographic nonce, WordPress nonces can be reused within their validity window (24 hours by default). Think of them as short-lived tokens that tie a specific action to a specific user session.
function my_plugin_render_settings_page(): void {
$options = get_option( 'my_plugin_options', [] );
$api_key = $options['api_key'] ?? '';
$webhook = $options['webhook_url'] ?? '';
$enabled = ! empty( $options['enabled'] );
?>
<div class="wrap">
<h1>h1>
<form method="post">
<table class="form-table">
<tbody><tr>
<th><label for="api_key">label>th>
<td>
<input type="text" id="api_key" name="api_key" value="$api_key ); ?>" class="regular-text">
td>
tr>
<tr>
<th><label for="webhook">label>th>
<td>
<input type="url" id="webhook" name="webhook_url" value="$webhook ); ?>" class="regular-text">
td>
tr>
<tr>
<th>th>
<td>
<input type="checkbox" name="enabled" value="1" php="" checked(="" $enabled="" );="" ?="">
>
td>
tr>
tbody>table>
form>
div>
PHPNotice that every dynamic value going into the HTML uses the correct escaping function for its context — esc_html() for plain text nodes, esc_attr() for attribute values, esc_url() for URLs.
If you ever bypass the WordPress API and write a custom SQL query using $wpdb, you need to use prepared statements. Without them, you’re open to SQL injection.
global $wpdb;
// Wrong — never do this
$results = $wpdb->get_results(
"SELECT * FROM {$wpdb->posts} WHERE post_author = " . $_GET['author_id']
);
// Correct — use prepare() with placeholders
$author_id = absint( $_GET['author_id'] ?? 0 );
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT ID, post_title FROM {$wpdb->posts} WHERE post_author = %d AND post_status = %s",
$author_id,
'publish'
)
);
PHP%d is the placeholder for integers, %s for strings, %f for floats. Never concatenate user input directly into a SQL string, even after sanitizing it — prepare() is the only safe approach here. For more on WordPress plugin development best practices beyond security, including code organization and performance, see the dedicated guide.
Here’s a complete AJAX handler that uses all four mechanisms correctly — capability check, nonce verification, sanitization, and escaped output:
add_action( 'wp_ajax_my_plugin_save_item', 'my_plugin_save_item_handler' );
function my_plugin_save_item_handler(): void {
// 1. Capability check
if ( ! current_user_can( 'edit_posts' ) ) {
wp_send_json_error( [ 'message' => 'Permission denied.' ], 403 );
}
// 2. Nonce check
if ( ! check_ajax_referer( 'my_plugin_save_item', 'nonce', false ) ) {
wp_send_json_error( [ 'message' => 'Security check failed.' ], 403 );
}
// 3. Sanitize incoming data
$title = sanitize_text_field( $_POST['title'] ?? '' );
$content = wp_kses_post( $_POST['content'] ?? '' );
$url = esc_url_raw( $_POST['url'] ?? '' );
$order = absint( $_POST['order'] ?? 0 );
if ( empty( $title ) ) {
wp_send_json_error( [ 'message' => 'Title is required.' ], 400 );
}
// 4. Do the work
$post_id = wp_insert_post( [
'post_title' => $title,
'post_content' => $content,
'post_status' => 'publish',
'post_type' => 'my_item',
'menu_order' => $order,
] );
if ( is_wp_error( $post_id ) ) {
wp_send_json_error( [ 'message' => $post_id->get_error_message() ], 500 );
}
if ( $url ) {
update_post_meta( $post_id, '_my_item_url', $url );
}
// 5. Return escaped output in the response
wp_send_json_success( [
'id' => $post_id,
'title' => esc_html( $title ),
] );
}
PHPThis pattern — check capability, verify nonce, sanitize input, do the work, escape output — applies to every form handler, AJAX callback, and REST endpoint you write. Once it becomes habit, you write it without thinking about it.
For the hook registration patterns used in the examples above, the WordPress actions and filters guide covers add_action(), priorities, and class-based hooks in detail. For how these security patterns fit into your overall WordPress plugin folder structure, keeping security logic in dedicated handler files makes auditing easier.
| Task | Function |
|---|---|
| Check user permission | current_user_can( $capability ) |
| Add nonce to form | wp_nonce_field( $action, $name ) |
| Verify form nonce | wp_verify_nonce( $_POST[$name], $action ) |
| Create nonce for JS | wp_create_nonce( $action ) |
| Verify AJAX nonce | check_ajax_referer( $action, $query_arg, false ) |
| Sanitize plain text | sanitize_text_field() |
| Sanitize textarea | sanitize_textarea_field() |
| Sanitize email | sanitize_email() |
| Sanitize URL for storage | esc_url_raw() |
| Sanitize integer | absint() |
| Sanitize HTML content | wp_kses_post() |
| Escape for HTML output | esc_html() |
| Escape for attribute | esc_attr() |
| Escape URL for output | esc_url() |
| Escape for JavaScript | esc_js() |
| Safe SQL query | $wpdb->prepare() |
Sanitization cleans data when it comes in — from a form submission or URL parameter — before you process or store it. Escaping makes data safe when it goes out — before you display it in HTML, an attribute, or a URL. You need both. Sanitizing without escaping still leaves you open to XSS if stored data is rendered raw. Escaping without sanitizing means you might store malformed data in the database.
Yes. A capability check confirms the user has permission. A nonce confirms the request actually came from your form in their browser — not from a malicious third-party site that submitted a request using their active session. They protect against different attack types and you need both.
Use sanitize_text_field() for any field where you expect plain text — names, titles, API keys, option values. It strips all HTML tags. Use wp_kses_post() only when the field intentionally accepts HTML content, like a WYSIWYG editor output. Using wp_kses_post() on a plain text field is not harmful but it’s unnecessary; using sanitize_text_field() on rich content will strip the HTML the user entered.
By default, WordPress nonces are valid for 24 hours. After 12 hours they enter a second “tick” — WordPress still accepts them but considers them less fresh. You can change the lifespan using the nonce_life filter, but the default is fine for most use cases.
You should escape it when outputting, but not re-sanitize it. If you sanitized correctly on save, the stored data is already clean. The reason to always escape on output is that you can’t guarantee every write path sanitized correctly — direct database edits, imports, or bugs in other code can leave unsanitized data in the database.

I’m Kamal, a WordPress developer focused on plugins, APIs, and scalable products.
Learn More