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

March 31, 2026·17 min read·
WordPress
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">
        <!--?php wp_nonce_field( 'my_plugin_save_settings', 'my_plugin_nonce' ); ?-->
        <!-- your form fields -->
        <input type="submit" value="Save Settings">
    </form>
    <!--?php
}

// Step 2: Verify the nonce when the form is submitted
function my_plugin_handle_form_submit(): void {
    // Capability check first
    if ( ! current_user_can( 'manage_options' ) ) {
        wp_die( 'Permission denied.' );
    }

    // Then verify the nonce
    if ( ! isset( $_POST['my_plugin_nonce'] ) ||
         ! wp_verify_nonce( $_POST['my_plugin_nonce'], 'my_plugin_save_settings' ) ) {
        wp_die( 'Security check failed.' );
    }

    // Now safe to process $_POST data
}
PHP

The two arguments to wp_nonce_field() and wp_verify_nonce() must match exactly — the action string ties the nonce to this specific operation.

Nonces in AJAX Requests

// PHP: pass the nonce to JavaScript
add_action( 'wp_enqueue_scripts', function() {
    wp_enqueue_script( 'my-plugin', plugin_dir_url( __FILE__ ) . 'js/plugin.js', [ 'jquery' ], '1.0', true );

    wp_localize_script( 'my-plugin', 'myPlugin', [
        'ajaxUrl' => admin_url( 'admin-ajax.php' ),
        'nonce'   => wp_create_nonce( 'my_plugin_ajax' ),
    ] );
} );

// JavaScript: include the nonce in the request
fetch( myPlugin.ajaxUrl, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
        action: 'my_plugin_action',
        nonce:  myPlugin.nonce,
        data:   'some value',
    }),
} );

// PHP: verify the nonce in the AJAX handler
add_action( 'wp_ajax_my_plugin_action', function() {
    if ( ! current_user_can( 'edit_posts' ) ) {
        wp_send_json_error( 'Permission denied.', 403 );
    }

    if ( ! check_ajax_referer( 'my_plugin_ajax', 'nonce', false ) ) {
        wp_send_json_error( 'Nonce verification failed.', 403 );
    }

    // safe to process request
    wp_send_json_success( 'Done.' );
} );
PHP

check_ajax_referer() is a wrapper around wp_verify_nonce() with the third parameter set to false so it returns a boolean instead of calling wp_die() — which lets you return a proper JSON error response instead of an HTML error page.

Sanitization: Cleaning Data Coming In

Sanitization strips or transforms data to make it safe to use. You sanitize anything that comes from outside your plugin — form submissions, URL parameters, API responses, database reads from untrusted sources.

WordPress ships a set of sanitization functions for different data types. Pick the one that matches what you’re expecting:

// Plain text — strips tags, removes extra whitespace
$name = sanitize_text_field( $_POST['name'] ?? '' );

// Textarea — like sanitize_text_field but preserves line breaks
$message = sanitize_textarea_field( $_POST['message'] ?? '' );

// Email address
$email = sanitize_email( $_POST['email'] ?? '' );

// URL
$url = esc_url_raw( $_POST['website'] ?? '' );

// Integer
$count = absint( $_POST['count'] ?? 0 );

// Float
$price = floatval( $_POST['price'] ?? 0 );

// Filename — strips path traversal characters
$filename = sanitize_file_name( $_FILES['upload']['name'] ?? '' );

// HTML content where you want to allow some tags (like a WYSIWYG field)
$content = wp_kses_post( $_POST['content'] ?? '' );

// Slug/key — only allows lowercase letters, numbers, dashes, underscores
$key = sanitize_key( $_POST['option_key'] ?? '' );
PHP

A practical example — handling a settings form with multiple field types:

function my_plugin_save_options(): void {
    if ( ! current_user_can( 'manage_options' ) ) {
        wp_die( 'Permission denied.' );
    }

    if ( ! wp_verify_nonce( $_POST['my_plugin_nonce'] ?? '', 'my_plugin_save' ) ) {
        wp_die( 'Security check failed.' );
    }

    // Sanitize each field individually before saving
    $options = [
        'api_key'      => sanitize_text_field( $_POST['api_key'] ?? '' ),
        'webhook_url'  => esc_url_raw( $_POST['webhook_url'] ?? '' ),
        'notify_email' => sanitize_email( $_POST['notify_email'] ?? '' ),
        'max_items'    => absint( $_POST['max_items'] ?? 10 ),
        'description'  => wp_kses_post( $_POST['description'] ?? '' ),
        'enabled'      => isset( $_POST['enabled'] ) ? 1 : 0,
    ];

    update_option( 'my_plugin_options', $options );

    wp_redirect( add_query_arg( 'updated', 'true', wp_get_referer() ) );
    exit;
}
PHP

One thing worth knowing about wp_kses_post(): it allows the same HTML tags that WordPress permits in post content — headings, paragraphs, links, lists, and so on. It strips script tags, event attributes, and anything else that could execute JavaScript. Use it only when you genuinely need to allow HTML. For plain text fields, sanitize_text_field() is always the right choice.

Escaping: Cleaning Data Going Out

Escaping is the counterpart to sanitization. Where sanitization cleans data on the way in, escaping makes data safe to output — preventing XSS (Cross-Site Scripting) by neutralizing any characters that could be interpreted as HTML or JavaScript.

The rule is: escape as late as possible, and escape for the specific context you’re outputting into.

$name    = get_option( 'my_plugin_name' );
$url     = get_option( 'my_plugin_url' );
$count   = get_option( 'my_plugin_count' );
$content = get_option( 'my_plugin_content' );
$key     = get_option( 'my_plugin_key' );

// HTML context — escapes <, >, &, ", '
echo esc_html( $name );

// HTML attribute context — use inside an attribute value
echo '<input type="text" value="' . esc_attr( $name ) . '">';

// URL context — encodes the URL for safe use in href/src
echo '<a href="' . esc_url( $url ) . '">Visit site</a>';

// JavaScript context — use when outputting a value into a JS string
echo '<script>var name = "' . esc_js( $name ) . '";</script>';

// Integer — just cast it, no escaping function needed
echo (int) $count;

// When you need to output allowed HTML (e.g. content you already sanitized with wp_kses_post)
echo wp_kses_post( $content );

// Translation strings with dynamic values — use esc_html__() or esc_html_e()
echo esc_html__( 'Settings saved.', 'my-plugin' );
printf( esc_html__( 'Found %d items.', 'my-plugin' ), (int) $count );
PHP

A mistake that shows up often: developers sanitize on input and then output the stored value raw, assuming “it was already cleaned”. Don’t rely on that. Escape at the point of output, every time. Data stored in the database is not safe to echo directly — it could have been written by a different code path that didn’t sanitize properly, or by direct database manipulation.

Escaping in Admin Settings Pages

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><!--?php echo esc_html( get_admin_page_title() ); ?--></h1>
        <form method="post">
            <!--?php wp_nonce_field( 'my_plugin_save', 'my_plugin_nonce' ); ?-->

            <table class="form-table">
                <tbody><tr>
                    <th><label for="api_key"><!--?php esc_html_e( 'API Key', 'my-plugin' ); ?--></label></th>
                    <td>
                        <input type="text" id="api_key" name="api_key" value="<?php echo esc_attr( $api_key ); ?>" class="regular-text">
                    </td>
                </tr>
                <tr>
                    <th><label for="webhook"><!--?php esc_html_e( 'Webhook URL', 'my-plugin' ); ?--></label></th>
                    <td>
                        <input type="url" id="webhook" name="webhook_url" value="<?php echo esc_url( $webhook ); ?>" class="regular-text">
                    </td>
                </tr>
                <tr>
                    <th><!--?php esc_html_e( 'Enable', 'my-plugin' ); ?--></th>
                    <td>
                        <input type="checkbox" name="enabled" value="1" <?php="" checked(="" $enabled="" );="" ?="">
                        >
                    </td>
                </tr>
            </tbody></table>

            <!--?php submit_button(); ?-->
        </form>
    </div>
    <!--?php
}
</code-->
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.