The Security Model
WordPress security is four separate checks — identity, capability, intent and data handling — with a named function for each.
current_user_can( string $capability, mixed ...$args ): boolWordPress 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
<?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.
<?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.
<?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.
<?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; forever.
Discussion