The Security Model

WordPress security is four separate checks — identity, capability, intent and data handling — with a named function for each.

Syntaxcurrent_user_can( string $capability, mixed ...$args ): bool

WordPress has no single security layer. It has a set of small functions, each answering one question, and nearly every vulnerability is a place where one of the questions was never asked.

Who are you

Authentication. WordPress resolves the logged-in user from the auth cookie during load and exposes it through wp_get_current_user(), with is_user_logged_in() as the boolean form. This happens before init, so do not ask about the current user earlier than that.

May you

Authorization. current_user_can( $capability ), and for meta capabilities an object ID: current_user_can( 'edit_post', $post_id ). Check capabilities, never roles — a capability check keeps working when a site adds a custom role, while $user->roles[0] === 'editor' does not.

Did you mean to

Intent. A nonce proves this user deliberately submitted this specific form, which is what stops cross-site request forgery. It is not authorization: a valid nonce from a subscriber is still a subscriber. You need both checks, every time.

Is the data safe

Two directions, two different jobs. Sanitize on the way in (sanitize_text_field(), absint()), escape on the way out (esc_html(), esc_attr()), and let $wpdb->prepare() build any query containing a variable.

The common mistake is treating those two as interchangeable. Sanitized data is not safe to print, and escaped data is not safe to store.

Example

Example · php
<?php
/**
 * Handler for admin-post.php?action=acme_delete_report
 * add_action( 'admin_post_acme_delete_report', 'acme_delete_report' );
 *
 * All four questions, in the order they have to be asked.
 */
function acme_delete_report() {

    // 1. Who are you / may you — capability, never role.
    if ( ! current_user_can( 'manage_options' ) ) {
        wp_die( esc_html__( 'You are not allowed to do that.', 'acme' ), '', array( 'response' => 403 ) );
    }

    // 2. Did you mean to — prints the "Are you sure?" screen and dies if the nonce fails.
    check_admin_referer( 'acme_delete_report' );

    // 3. Is the data safe coming in — unslash, then force the type.
    $report_id = isset( $_POST['report_id'] ) ? absint( wp_unslash( $_POST['report_id'] ) ) : 0;
    if ( ! $report_id ) {
        wp_die( esc_html__( 'Missing report.', 'acme' ), '', array( 'response' => 400 ) );
    }

    // 4. Is the data safe going to the database — delete() escapes the values itself.
    global $wpdb;
    $wpdb->delete( $wpdb->prefix . 'acme_reports', array( 'id' => $report_id ), array( '%d' ) );

    wp_safe_redirect( admin_url( 'admin.php?page=acme-reports&deleted=1' ) );
    exit;
}

When to use it

  • Reviewing a plugin's form handler and needing a checklist of what must be present before it saves anything
  • Deciding whether a failing permission bug is an authentication, capability or nonce problem
  • Writing an admin-post or AJAX endpoint from scratch and getting the order of checks right
  • Explaining to a reviewer why current_user_can() is required even though the page is inside wp-admin

More examples

The same four checks in a REST route

REST routes answer the identity and capability questions in permission_callback rather than inline, and the sanitize/validate callbacks in args handle input before your handler ever runs. Nonce handling is different too: the cookie authentication path uses the X-WP-Nonce header for the wp_rest action, so you do not call check_admin_referer() here. Reach for this shape whenever the request comes from JavaScript rather than a form POST.

Example · php
<?php
add_action( 'rest_api_init', function () {
    register_rest_route( 'acme/v1', '/reports/(?P<id>\d+)', array(
        'methods'  => WP_REST_Server::DELETABLE,

        // "May you" lives here. Returning false or WP_Error stops the request.
        'permission_callback' => function () {
            return current_user_can( 'manage_options' );
        },

        // "Is the data safe" lives here, per argument.
        'args' => array(
            'id' => array(
                'required'          => true,
                'sanitize_callback' => 'absint',
                'validate_callback' => function ( $value ) {
                    return $value > 0;
                },
            ),
        ),

        'callback' => function ( WP_REST_Request $request ) {
            $id = $request['id']; // already absint()-ed by the sanitize_callback
            global $wpdb;
            $wpdb->delete( $wpdb->prefix . 'acme_reports', array( 'id' => $id ), array( '%d' ) );
            return new WP_REST_Response( null, 204 );
        },
    ) );
} );

Capability check, not a role check

The two lines look equivalent on a default install and diverge the moment anyone installs a membership plugin, a custom role, or grants an extra capability to a single user. current_user_can() also routes through map_meta_cap(), so meta capabilities like edit_post resolve to the real per-object answer. Use this whenever you are tempted to compare a role string.

Example · php
<?php
$user = wp_get_current_user();

// Wrong: breaks for custom roles, for multiple roles, and for super admins.
if ( in_array( 'editor', (array) $user->roles, true ) ) {
    acme_show_moderation_tools();
}

// Right: asks the question you actually mean.
if ( current_user_can( 'edit_others_posts' ) ) {
    acme_show_moderation_tools();
}

// Meta capability — resolved against one specific post, including its author
// and its post type's capability mapping.
if ( current_user_can( 'edit_post', $post_id ) ) {
    acme_show_inline_editor( $post_id );
}

Sanitize in, escape out — not interchangeable

This is the distinction people collapse most often. Sanitizing produces a value fit to store; escaping produces a value fit to print into one specific context. Storing escaped data means entities accumulate in the database and leak into emails, exports and the REST API; printing sanitized data means anything the sanitizer legitimately allowed through can still break the page. Do both, at the right ends.

Example · php
<?php
// IN — one sanitizer per field, chosen by what the field holds.
$title = sanitize_text_field( wp_unslash( $_POST['acme_title'] ?? '' ) );
$body  = wp_kses_post( wp_unslash( $_POST['acme_body'] ?? '' ) ); // keeps post-safe HTML
update_post_meta( $post_id, '_acme_title', $title );
update_post_meta( $post_id, '_acme_body', $body );

// OUT — escape at the echo, by context, every time.
$title = get_post_meta( $post_id, '_acme_title', true );
$body  = get_post_meta( $post_id, '_acme_body', true );

printf(
    '<h2 data-title="%1$s">%2$s</h2>',
    esc_attr( $title ),  // attribute context
    esc_html( $title )   // text context — the same value, escaped differently
);
echo wp_kses_post( $body ); // HTML that must survive, filtered against the post allowlist

// Do NOT store esc_html( $title ): the database ends up holding &amp;amp; forever.

Discussion

  • Be the first to comment on this lesson.