LW IT Solutions logo LW IT Solutions logo LW IT Solutions
« Blog Overview /WordPress Plugins & Tricks / Sanitizing, Escaping, Nonces, Capabilities in Plugin Review
Read this article in other languages:

Sanitizing, Escaping, Nonces, Capabilities in Plugin Review

Sanitizing, Escaping, Nonces, Capabilities in Plugin Review

Management Summary

WordPress plugins need several complementary safeguards when accepting settings or displaying saved content. Permissions determine which accounts may make changes, request checks help resist actions triggered by hostile pages, and input rules keep unsuitable data out. Safe output prevents stored text from becoming unwanted instructions in the browser. These protections solve different problems and cannot replace one another. Understanding the distinction helps teams assess plugin quality beyond a successful automated check.

Contents
  1. What each check means
  2. 1. Types, cleaning and value rules
  3. 2. Protection inside the save handler
  4. 3. Escaping at the final HTML output
  5. 4. SQL needs placeholders and allowed names
  6. 5. REST permission and cleaned-value checks
  7. What the checks prove
  8. Questions and answers
  9. Sources

A plugin can reject a forged request and still store unsafe data. It can also clean every field and still allow the wrong account to change settings. Four separate checks prevent different mistakes: permission, a request token, valid input and safe output.

The examples use a notice with a label, an optional link, limited HTML and a row count. The code blocks are cooperating teaching examples, not a complete installable plugin: the settings screen, plugin header, translations, installation and database-error handling still belong in the surrounding plugin.

What each check means

CheckConcrete questionExample
CapabilityDoes this account have permission to change site settings?current_user_can('manage_options')
NonceDoes this request carry a token valid for this action and session?wp_verify_nonce()
ValidationIs the row count a whole number from 1 to 50?Reject -1, 1.5 and arrays.
SanitizingWhich parts of this text or HTML may remain?sanitize_text_field(), wp_kses_post()
EscapingCan this value safely appear in this HTML position?esc_attr() for an attribute.

A WordPress nonce is not a one-time password and does not prove that a person intended an action or used a particular form. It helps prevent cross-site request forgery (CSRF), where a hostile page tries to trigger an action using an existing login. A token can be reused during its validity period; by default this is roughly 12 to 24 hours. Permission checks remain necessary.

Three separate groups: permission and token, valid values, and parameterized storage with context-specific output escaping.
Permission, request protection and data handling need separate checks.

1. Types, cleaning and value rules

A field named link can arrive as an array, a collection of values, through link[]=value. Text-oriented functions should not receive it unchecked. The helper below accepts exactly the four expected fields as strings, ignores additional fields and returns either a cleaned array or WP_Error. It expects input that has already passed through wp_unslash() when coming from WordPress form globals.

PHP
<?php
function lw_nb_review_label( $raw ) {
    if ( ! is_string( $raw ) ) {
        return new WP_Error( 'invalid_label', 'Label must be text.', array( 'status' => 400 ) );
    }
    $label = sanitize_text_field( $raw );
    if ( 1 !== preg_match( '/^.{1,100}$/us', $label ) ) {
        return new WP_Error( 'invalid_label', 'Label must contain 1 to 100 characters after cleaning.', array( 'status' => 400 ) );
    }
    return $label;
}

function lw_nb_review_values( $input ) {
    if ( ! is_array( $input ) ) {
        return new WP_Error( 'invalid_input', 'Expected form fields.', array( 'status' => 400 ) );
    }
    foreach ( array( 'label', 'link', 'message', 'rows' ) as $key ) {
        if ( ! isset( $input[ $key ] ) || ! is_string( $input[ $key ] ) ) {
            return new WP_Error( 'invalid_input', 'A required text field is missing or malformed.', array( 'status' => 400 ) );
        }
    }
    $label = lw_nb_review_label( $input['label'] );
    if ( is_wp_error( $label ) ) {
        return $label;
    }
    if ( 1 !== preg_match( '/^(?:[1-9]|[1-4][0-9]|50)$/D', $input['rows'] ) ) {
        return new WP_Error( 'invalid_rows', 'Rows must be an integer from 1 to 50.', array( 'status' => 400 ) );
    }
    $link = sanitize_url( $input['link'], array( 'http', 'https' ) );
    $parts = wp_parse_url( $input['link'] );
    if ( '' !== $input['link'] && (
        '' === $link || false === $parts || empty( $parts['host'] ) ||
        empty( $parts['scheme'] ) ||
        ! in_array( strtolower( $parts['scheme'] ), array( 'http', 'https' ), true )
    ) ) {
        return new WP_Error( 'invalid_link', 'Link must be empty or an absolute HTTP(S) URL.', array( 'status' => 400 ) );
    }
    return array(
        'label' => $label,
        'link' => $link,
        'message' => wp_kses_post( $input['message'] ),
        'rows' => (int) $input['rows'],
    );
}

wp_unslash() removes the extra slashes WordPress adds to form input. It is not a security check. REST parameters do not need this form-specific step. absint() also is not validation: it can turn -5 into 5 and truncate fractional values. The row-count pattern instead rejects values outside the chosen format.

The label is checked after cleaning too. For example, <b></b> has characters before cleaning but becomes empty afterwards. The helper rejects it. The 100-character limit counts Unicode code points, not necessarily 100 visible symbols; a letter plus a combining accent can use two code points. The URL rule allows an empty value or an absolute HTTP(S) URL with a host. It does not establish that a destination is trustworthy or safe for a server-side fetch.

2. Protection inside the save handler

Restricting the settings menu does not protect a separate admin_post_... handler. The handler must check permission again. The form uses method="post", targets admin_url('admin-post.php') escaped with esc_url(), includes the hidden action lw_nb_review_save and outputs wp_nonce_field('lw_nb_review_save', 'lw_nb_nonce'). The following handler checks the method, capability and token before saving:

PHP
<?php
add_action( 'admin_post_lw_nb_review_save', 'lw_nb_review_save' );

function lw_nb_review_save() {
    if ( 'POST' !== ( $_SERVER['REQUEST_METHOD'] ?? '' ) ) {
        wp_die( 'POST required.', '', array( 'response' => 405 ) );
    }
    if ( ! current_user_can( 'manage_options' ) ) {
        wp_die( 'Permission denied.', '', array( 'response' => 403 ) );
    }
    $nonce = isset( $_POST['lw_nb_nonce'] ) && is_string( $_POST['lw_nb_nonce'] )
        ? sanitize_text_field( wp_unslash( $_POST['lw_nb_nonce'] ) )
        : '';
    if ( ! wp_verify_nonce( $nonce, 'lw_nb_review_save' ) ) {
        wp_die( 'Invalid request token.', '', array( 'response' => 403 ) );
    }
    $values = lw_nb_review_values( wp_unslash( $_POST ) );
    if ( is_wp_error( $values ) ) {
        wp_die( esc_html( $values->get_error_message() ), '', array( 'response' => 400 ) );
    }
    update_option( 'lw_nb_review_notice', $values );
    wp_safe_redirect( admin_url( 'options-general.php' ) );
    exit;
}

The capability identifies a permission rather than a role name. For editing a particular post, an object-aware check such as current_user_can('edit_post', $post_id) is more appropriate. A subscriber’s own valid token does not grant manage_options. A leaked token from another account is not automatically valid for the subscriber’s session either.

3. Escaping at the final HTML output

Saved data remains untrusted: an import, older plugin version or another write path may have changed it. Escaping belongs where HTML is finally built, including a string returned by a shortcode. The function depends on the output position:

PositionFunction
Text between tagsesc_html()
Quoted attribute valueesc_attr()
URL in href, src or actionesc_url()
Textarea contentsesc_textarea()
Limited post-style HTMLwp_kses_post()

For example, esc_html($label) displays a label as text, while wp_kses_post($message) allows the permitted HTML in a message. These operations are not interchangeable. sanitize_url() cleans a URL for storage; it does not replace esc_url() at HTML output. A JSON response is a different output format: HTML escaping should happen where a client later inserts a value into HTML.

4. SQL needs placeholders and allowed names

Cleaning text does not make string concatenation safe in SQL. The example below assumes an existing custom log table. It permits only three sort columns and limits the number of returned rows. The variables come from validated values rather than raw query parameters.

PHP
<?php
function lw_nb_review_get_log( $orderby, $limit ) {
    global $wpdb;
    $allowed = array( 'saved_at', 'label', 'user_id' );
    if ( ! is_string( $orderby ) || ! in_array( $orderby, $allowed, true ) ) {
        $orderby = 'saved_at';
    }
    if ( ! is_int( $limit ) || $limit < 1 || $limit > 50 ) {
        $limit = 10;
    }
    return $wpdb->get_results(
        $wpdb->prepare(
            'SELECT user_id, label, saved_at FROM %i ORDER BY %i DESC LIMIT %d',
            $wpdb->prefix . 'lw_nb_log',
            $orderby,
            $limit
        )
    );
}

%i quotes a table or column identifier and requires WordPress 6.2 or later. The allowlist decides which identifier is permitted; the placeholder alone does not make that decision. Values use placeholders such as %s and %d, or a format list with $wpdb->insert(). An added database column does not automatically belong in the sorting allowlist.

5. REST permission and cleaned-value checks

A REST route is an address through which software requests data or changes. These routes need an explicit permission_callback. A missing callback can leave a route public despite the warning issued by WordPress. For intentionally public data, __return_true makes that choice visible. The write route below permits only accounts with manage_options and exposes POST only.

PHP
<?php
add_action( 'rest_api_init', function () {
    register_rest_route( 'lw-nb-review/v1', '/notice', array(
        'methods' => WP_REST_Server::CREATABLE,
        'permission_callback' => function () {
            return current_user_can( 'manage_options' );
        },
        'args' => array(
            'label' => array(
                'type' => 'string',
                'required' => true,
                'minLength' => 1,
                'maxLength' => 100,
                'validate_callback' => 'rest_validate_request_arg',
                'sanitize_callback' => 'lw_nb_review_label',
            ),
        ),
        'callback' => function ( WP_REST_Request $request ) {
            $label = lw_nb_review_label( $request->get_param( 'label' ) );
            if ( is_wp_error( $label ) ) {
                return $label;
            }
            $stored = get_option( 'lw_nb_review_notice', array() );
            $stored = is_array( $stored ) ? $stored : array();
            $stored['label'] = $label;
            update_option( 'lw_nb_review_notice', $stored );
            return rest_ensure_response( array( 'label' => $label ) );
        },
    ) );
} );

The explicit schema validation checks the incoming type and length. The custom sanitizer also checks the cleaned label, closing the empty-after-cleaning case. With cookie authentication, core checks a wp_rest nonce supplied through X-WP-Nonce or _wpnonce. Without that nonce, cookie-authenticated REST requests are treated as unauthenticated. Other authentication methods, such as application passwords, have their own authentication flow; the capability check still applies.

What the checks prove

The helper functions passed 41 isolated cases using WordPress 7.1.3 and PHP 8.4.27: arrays, missing fields, negative and fractional row counts, empty cleaned labels, URL protocols and allowed HTML. PHP syntax checks also passed for the helper, form-handler and REST snippets. The save handler and REST route were not executed against live settings. These results do not claim a full plugin audit or a clean Plugin Check report.

A complete integration also needs tests with an authorized account, a lower-privileged account and no login; missing or stale tokens; database failures; and escaped output in the browser. Plugin Check findings are useful clues, but an empty report cannot prove that every operation has the right permission check.

Questions and answers

Does a read-only sort parameter need a nonce?

Changing display order does not itself modify stored data. The parameter still needs type checking and an allowlist, and access to private results still needs authorization. A nonce is not a substitute for either.

Does a nonce stop repeated submissions?

No. WordPress tokens can be reused during their validity period. Payments or other actions that must occur once need a separate duplicate-prevention mechanism.

Is sanitizing once enough?

No. Input rules, permission checks, parameterized SQL and context-specific output protection have different jobs. A cleaned label can still contain a quotation mark that needs escaping in an HTML attribute.

Lukas Wojcik

Lukas Wojcik

Systems architect and technology enthusiast specializing in scalable tracking solutions, GMP Stack (GA4 & GTM), and robust backend architectures. Advocate for clean code and privacy-first design.

Get in touch

A question or an idea for a web project? Tell me a little about it. This form uses reCAPTCHA to protect against spam.

Write a comment

Experience with other plugins or hosting environments and questions about the setup are welcome here.

The email address is not published. Required fields are marked with an asterisk.