WordPress Plugin Security: Nonces, Sanitization, Escaping & Capability Checks

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.

Capability Checks: Who Can Do This?

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
}
PHP

manage_options is the capability tied to the Administrator role. Here are the ones you’ll use most often:

CapabilityWho has itUse when
manage_optionsAdministratorPlugin settings, site-wide config
edit_postsEditor, Author, ContributorCreating or editing content
publish_postsEditor, AuthorPublishing content
edit_others_postsEditorEditing other users’ content
manage_categoriesEditor, AdministratorCategory/taxonomy management
upload_filesAuthor and aboveMedia 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
} );
PHP

Nonces: Verifying the Request Is Legitimate

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

Nonces in Admin Forms

// Step 1: Output the nonce field in your form
function my_plugin_settings_form(): void {
    ?>
    <form method="post" action="options.php">
        
        
        <input type="submit" value="Save Settings">
    form>
    
        
>
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>
    
PHP

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

Direct Database Queries

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.

Putting It All Together

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 ),
    ] );
}
PHP

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

Quick Reference

TaskFunction
Check user permissioncurrent_user_can( $capability )
Add nonce to formwp_nonce_field( $action, $name )
Verify form noncewp_verify_nonce( $_POST[$name], $action )
Create nonce for JSwp_create_nonce( $action )
Verify AJAX noncecheck_ajax_referer( $action, $query_arg, false )
Sanitize plain textsanitize_text_field()
Sanitize textareasanitize_textarea_field()
Sanitize emailsanitize_email()
Sanitize URL for storageesc_url_raw()
Sanitize integerabsint()
Sanitize HTML contentwp_kses_post()
Escape for HTML outputesc_html()
Escape for attributeesc_attr()
Escape URL for outputesc_url()
Escape for JavaScriptesc_js()
Safe SQL query$wpdb->prepare()

Frequently Asked Questions

What is the difference between sanitization and escaping in WordPress?

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.

Do I need a nonce if I’m already checking capabilities?

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.

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

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.

How long does a WordPress nonce last?

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.

Should I sanitize data when reading it from the database?

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.

About Me

Gemini_Generated_Image_6ed8rn6ed8rn6ed8

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

Learn More