Privilege escalation vulnerabilities are among the most dangerous security issues in WordPress plugins. A single missed capability check can allow a subscriber to perform administrative actions, modify other users' content, or compromise the entire site. Yet in my audits of hundreds of WordPress plugins, I consistently find developers implementing capability checks incorrectly—or worse, skipping them entirely.
The problem is that many developers think of capabilities as a simple on/off gate. Either a user has access or they don't. In reality, WordPress capabilities are nuanced. There are built-in capabilities, custom capabilities, meta capabilities that depend on context, and numerous ways to implement them incorrectly. A developer might check the right capability in one place and the wrong one in another, creating an inconsistent security posture that's ripe for exploitation.
WordPress user role and capability checks security is fundamental to preventing broken access control vulnerabilities. This guide walks you through the entire capability system, shows you the vulnerable patterns that attackers exploit, and demonstrates how to implement rock-solid permission checks that actually prevent privilege escalation.
Table of Contents
- Understanding WordPress Capabilities
- Built-in Capabilities and Roles
- Meta Capabilities and Context
- Implementing Proper Capability Checks
- Custom Capabilities and Registration
- Common Vulnerability Patterns
- Best Practices for Access Control
- Frequently Asked Questions
Understanding WordPress Capabilities
A capability is a granular permission that determines what actions a user can perform. Instead of thinking about users and roles, think about capabilities. Roles are simply collections of capabilities assigned together.
This distinction is critical for security. A role is just a label; a capability is the actual permission. Two sites might define the "Manager" role differently—one might include manage_options while another doesn't. By checking capabilities instead of roles, your code works correctly regardless of how roles are configured.
Think of capabilities as answering the question "Can this user perform this specific action?" In your code, you should answer this question frequently. Before a user deletes a post, can they? Before they edit another user, can they? Before they modify site settings, can they? These questions get answered by capability checks.
For example, WordPress has these default roles:
- Subscriber: Can only read published content
- Contributor: Can write and manage their own posts (not published)
- Author: Can publish and manage their own posts
- Editor: Can publish and manage all posts
- Administrator: Has all capabilities
Each role has specific capabilities. An Administrator has the manage_options capability, while a Subscriber doesn't. When you check permissions in your code, you're checking capabilities, not roles.
Here's the critical distinction:
// WRONG: Checking by role (don't do this)
$user = wp_get_current_user();
if ( in_array( 'administrator', $user->roles ) ) {
// Let user do something
}
// RIGHT: Checking by capability
if ( current_user_can( 'manage_options' ) ) {
// Let user do something
}
Why is checking by capability better than checking by role? Because roles can be customized. A plugin might add the manage_options capability to a custom "Manager" role that isn't the built-in Administrator role. If you check by role name, you'll miss users with the necessary capability.
The current_user_can() function checks capabilities, and this is what you should always use:
function my_admin_page() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( 'You do not have permission to access this page.' );
}
// User is authorized, proceed with functionality
echo 'Admin content here';
}
add_action( 'admin_menu', function() {
add_menu_page(
'My Plugin',
'My Plugin',
'manage_options', // Capability required
'my-plugin',
'my_admin_page'
);
} );
WordPress requires you to specify the 'manage_options' capability when registering the menu page. If a user doesn't have this capability, WordPress won't display the menu item. But it's still good practice to double-check in your callback function because developers can sometimes bypass menu security checks.
Built-in Capabilities and Roles
WordPress comes with numerous built-in capabilities. Understanding the most common ones is essential for implementing proper access control.
Standard Post Capabilities
These control access to post-related functionality:
read_posts: Read published postscreate_posts: Create new postsedit_posts: Edit postsedit_others_posts: Edit posts created by other userspublish_posts: Publish postsdelete_posts: Delete posts
Each post type can have custom capabilities. For example, custom post types might have edit_custom_posts instead of edit_posts.
Administrative Capabilities
manage_options: Access WordPress settings and admin pagesmanage_plugins: Manage pluginsmanage_themes: Manage themesmanage_users: Add, edit, and delete usersmoderate_comments: Moderate comments
These administrative capabilities are where many privilege escalation vulnerabilities occur. A missing manage_options check on a settings page allows anyone to modify site-wide configuration. A missing manage_plugins check allows subscribers to install malicious plugins. Checking these capabilities is non-negotiable for any admin functionality.
Custom Post Type Capabilities
When registering a custom post type, you can specify custom capability names:
register_post_type( 'project', array(
'label' => 'Projects',
'capability_type' => 'project', // Use 'project' as base for capabilities
'capabilities' => array(
'read' => 'read_projects',
'create_posts' => 'create_projects',
'edit_posts' => 'edit_projects',
'edit_others_posts' => 'edit_others_projects',
'publish_posts' => 'publish_projects',
'delete_posts' => 'delete_projects',
),
) );
Now instead of edit_posts, you check edit_projects specifically.
Meta Capabilities and Context
Meta capabilities are capabilities that depend on context. The classic example is edit_post, which requires knowing which post is being edited.
When you check current_user_can( 'edit_post', $post_id ), WordPress translates this meta capability into the appropriate primitive capability based on whether the user is the post author:
// If user is post author: checks 'edit_posts'
// If user is not author: checks 'edit_others_posts'
if ( current_user_can( 'edit_post', $post_id ) ) {
// User can edit this specific post
}
This is where many developers make mistakes. They check a capability without considering the context:
// WRONG: Only checks if user can edit posts in general
if ( current_user_can( 'edit_posts' ) ) {
// User might be author of this post or admin
// But could also be an author trying to edit someone else's post
}
// RIGHT: Checks if user can edit this specific post
if ( current_user_can( 'edit_post', $post_id ) ) {
// User can definitely edit THIS post
}
Here are common meta capabilities:
edit_post,delete_post,read_post: Specific post operationsdelete_user: Delete a specific user (context is user ID)edit_user,edit_users: Edit usersmanage_post_type: Manage a specific post type
When in doubt, pass context. If you're checking access to a specific object, pass that object's ID as the second parameter.
Implementing Proper Capability Checks
Now let's look at how to properly implement capability checks in different scenarios.
Checking Edit Access to Content
// VULNERABLE: Only checks generic edit_posts capability
function maybe_edit_post( $post_id ) {
if ( ! current_user_can( 'edit_posts' ) ) {
return new WP_Error( 'permission_denied', 'Cannot edit posts' );
}
// Edit the post...
}
// SECURE: Checks specific capability for this post
function maybe_edit_post( $post_id ) {
if ( ! current_user_can( 'edit_post', $post_id ) ) {
return new WP_Error( 'permission_denied', 'Cannot edit this post' );
}
// Edit the post...
}
The second version correctly uses the meta capability edit_post with the post ID, which properly checks whether this user can edit this specific post.
Checking Delete Access
// VULNERABLE: Checks only delete_posts, not for this specific post
function delete_post_handler( $post_id ) {
if ( ! current_user_can( 'delete_posts' ) ) {
wp_die( 'Permission denied' );
}
wp_delete_post( $post_id );
}
// SECURE: Checks delete_post meta capability
function delete_post_handler( $post_id ) {
if ( ! current_user_can( 'delete_post', $post_id ) ) {
wp_die( 'Permission denied' );
}
wp_delete_post( $post_id );
}
By using delete_post with the specific post ID, you ensure the user has permission to delete this exact post, not just posts in general.
Checking User Management Access
// VULNERABLE: Only checks if user can manage users, not if they can manage this user
function edit_user_handler( $user_id ) {
if ( ! current_user_can( 'manage_users' ) ) {
wp_die( 'Permission denied' );
}
// Edit the user...
}
// SECURE: Checks if user can edit this specific user
function edit_user_handler( $user_id ) {
if ( ! current_user_can( 'edit_user', $user_id ) ) {
wp_die( 'Permission denied' );
}
// Edit the user...
}
The second version properly checks edit_user with the specific user ID.
Preventing Self-Privilege Escalation
A common vulnerability is allowing users to modify their own role or capabilities:
// VULNERABLE: User can modify their own capabilities
function update_user_role( $user_id, $role ) {
$user = new WP_User( $user_id );
$user->set_role( $role );
}
// Called from REST endpoint or admin form
if ( ! empty( $_POST['user_id'] ) && ! empty( $_POST['role'] ) ) {
update_user_role( $_POST['user_id'], $_POST['role'] );
}
An attacker could change their own role to Administrator.
// SECURE: Prevent users from modifying their own role
function update_user_role( $user_id, $role ) {
$current_user = wp_get_current_user();
// Prevent self-modification
if ( $current_user->ID === $user_id ) {
return new WP_Error( 'self_modification', 'You cannot modify your own role' );
}
// Check if user can manage users
if ( ! current_user_can( 'manage_users' ) ) {
return new WP_Error( 'permission_denied', 'You cannot manage users' );
}
$user = new WP_User( $user_id );
$user->set_role( $role );
return true;
}
This prevents users from escalating their own privileges.
Custom Capabilities and Registration
Most plugins need custom capabilities beyond WordPress's built-in ones. Here's how to implement them properly.
Registering Custom Capabilities
function register_custom_capabilities() {
// Grant the 'manage_projects' capability to administrators
$admin_role = get_role( 'administrator' );
if ( $admin_role ) {
$admin_role->add_cap( 'manage_projects' );
$admin_role->add_cap( 'manage_project_settings' );
$admin_role->add_cap( 'view_project_reports' );
}
// Also grant some capabilities to editors
$editor_role = get_role( 'editor' );
if ( $editor_role ) {
$editor_role->add_cap( 'manage_projects' );
$editor_role->add_cap( 'view_project_reports' );
}
}
add_action( 'init', 'register_custom_capabilities' );
This should be done once during plugin activation or on plugin load for fresh installations. Here's how to handle this in a plugin:
function my_plugin_activate() {
// Register capabilities on activation
register_custom_capabilities();
}
register_activation_hook( __FILE__, 'my_plugin_activate' );
// Also run on admin init for existing installations
add_action( 'admin_init', function() {
// Check if custom capability exists
$admin_role = get_role( 'administrator' );
if ( $admin_role && ! $admin_role->has_cap( 'manage_projects' ) ) {
register_custom_capabilities();
}
} );
Using Custom Capabilities in Code
// Checking custom capability
if ( ! current_user_can( 'manage_projects' ) ) {
wp_die( 'You do not have permission to manage projects' );
}
// In REST endpoints
register_rest_route( 'my-plugin/v1', '/projects', array(
'methods' => 'GET',
'callback' => 'get_projects',
'permission_callback' => function() {
return current_user_can( 'manage_projects' );
},
) );
// In admin menu pages
add_menu_page(
'Projects',
'Projects',
'manage_projects', // Custom capability
'my-plugin-projects',
'projects_admin_page'
);
Cleaning Up Capabilities on Deactivation
When deactivating a plugin, remove custom capabilities:
function my_plugin_deactivate() {
// Remove custom capabilities
$admin_role = get_role( 'administrator' );
if ( $admin_role ) {
$admin_role->remove_cap( 'manage_projects' );
$admin_role->remove_cap( 'manage_project_settings' );
$admin_role->remove_cap( 'view_project_reports' );
}
$editor_role = get_role( 'editor' );
if ( $editor_role ) {
$editor_role->remove_cap( 'manage_projects' );
$editor_role->remove_cap( 'view_project_reports' );
}
}
register_deactivation_hook( __FILE__, 'my_plugin_deactivate' );
Common Vulnerability Patterns
Let me show you real vulnerability patterns that I've found in production WordPress plugins.
Pattern 1: Checking Role Instead of Capability
// VULNERABLE: Checking specific role
$user = wp_get_current_user();
if ( ! in_array( 'administrator', $user->roles ) ) {
wp_die( 'Administrators only' );
}
// SECURE: Checking capability
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( 'Permission denied' );
}
Checking roles breaks when capabilities are customized or assigned to different roles. Always check capabilities.
Pattern 2: No Capability Check on AJAX Handlers
// VULNERABLE: No capability check
add_action( 'wp_ajax_save_widget_settings', function() {
$settings = sanitize_text_field( $_POST['settings'] );
update_option( 'my_widget_settings', $settings );
wp_send_json_success();
} );
// SECURE: Proper capability check
add_action( 'wp_ajax_save_widget_settings', function() {
check_ajax_referer( 'widget_nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( 'Permission denied', 403 );
}
$settings = sanitize_text_field( $_POST['settings'] );
update_option( 'my_widget_settings', $settings );
wp_send_json_success();
} );
AJAX handlers need the same security as regular functions: nonces and capability checks.
Pattern 3: Trusting User Input for User ID
// VULNERABLE: Trusts user to specify which user they're viewing
function view_user_profile() {
$user_id = intval( $_GET['user_id'] );
$user = get_userdata( $user_id );
echo 'Email: ' . esc_html( $user->user_email );
echo 'Phone: ' . esc_html( get_user_meta( $user_id, 'phone', true ) );
}
// SECURE: Check capability for viewing other users' data
function view_user_profile() {
$user_id = intval( $_GET['user_id'] );
$current_user = wp_get_current_user();
// User can view their own profile
if ( $current_user->ID === $user_id ) {
$user = get_userdata( $user_id );
echo 'Email: ' . esc_html( $user->user_email );
return;
}
// Only admins can view other users
if ( ! current_user_can( 'manage_users' ) ) {
wp_die( 'Permission denied' );
}
$user = get_userdata( $user_id );
echo 'Email: ' . esc_html( $user->user_email );
echo 'Phone: ' . esc_html( get_user_meta( $user_id, 'phone', true ) );
}
Pattern 4: Exposing Admin Functionality Without Capability Checks
// VULNERABLE: Admin functions accessible to anyone
function handle_bulk_delete() {
$post_ids = $_POST['post_ids'];
foreach ( $post_ids as $post_id ) {
wp_delete_post( $post_id );
}
wp_send_json_success();
}
add_action( 'wp_ajax_bulk_delete', 'handle_bulk_delete' );
// SECURE: Requires proper capability checks
function handle_bulk_delete() {
check_ajax_referer( 'bulk_delete_nonce' );
if ( ! current_user_can( 'delete_posts' ) ) {
wp_send_json_error( 'Permission denied', 403 );
}
$post_ids = isset( $_POST['post_ids'] ) ? array_map( 'intval', $_POST['post_ids'] ) : array();
foreach ( $post_ids as $post_id ) {
if ( ! current_user_can( 'delete_post', $post_id ) ) {
continue; // Skip if user can't delete this post
}
wp_delete_post( $post_id );
}
wp_send_json_success( 'Posts deleted' );
}
add_action( 'wp_ajax_bulk_delete', 'handle_bulk_delete' );
Notice how the secure version checks the capability for each individual post, not just once for the entire operation.
Pattern 5: Missing Capability Check in Template Files
// VULNERABLE: Template doesn't check capabilities
// In theme template file
<?php
if ( isset( $_POST['save_data'] ) ) {
update_option( 'my_data', $_POST['my_data'] );
echo 'Data saved';
}
?>
// SECURE: Proper capability check in template
<?php
if ( isset( $_POST['save_data'] ) ) {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( 'Permission denied' );
}
update_option( 'my_data', sanitize_text_field( $_POST['my_data'] ) );
echo 'Data saved';
}
?>
Even in template files, you need to check capabilities before processing sensitive operations.
Best Practices for Access Control
Here are the key principles for implementing secure capability checks throughout your WordPress code:
1. Always check capabilities before sensitive operations
if ( ! current_user_can( 'appropriate_capability', $context ) ) {
return new WP_Error( 'permission_denied', 'You do not have permission' );
}
2. Use meta capabilities when context exists
// Good: Includes context
current_user_can( 'edit_post', $post_id )
current_user_can( 'delete_user', $user_id )
// Poor: Missing context
current_user_can( 'edit_posts' )
current_user_can( 'manage_users' )
3. Sanitize input before checking capabilities
$post_id = intval( $_POST['post_id'] ); // Sanitize first
if ( ! current_user_can( 'edit_post', $post_id ) ) { // Then check
return new WP_Error( 'permission_denied' );
}
4. Check capabilities in both frontend and AJAX handlers
// Not just in admin
add_action( 'wp_ajax_nopriv_frontend_action', 'handle_frontend_action' );
function handle_frontend_action() {
if ( ! current_user_can( 'appropriate_capability' ) ) {
wp_send_json_error( 'Permission denied', 403 );
}
// Process action
}
5. Prevent privilege escalation by blocking self-modification
$current_user = wp_get_current_user();
if ( $current_user->ID === $target_user_id ) {
return new WP_Error( 'cannot_modify_self' );
}
6. Use WordPress capabilities, not custom checks
// Good: Uses WordPress capability system
if ( ! current_user_can( 'manage_options' ) ) {
}
// Bad: Custom logic that breaks with role customization
if ( $user->role !== 'administrator' ) {
}
When implementing WordPress user role and capability checks security, remember that this is your primary defense against privilege escalation attacks. A single missed check can allow attackers to compromise your entire site. That's why it's critical to audit your code thoroughly.
WP HealthKit automatically scans your plugin for capability check vulnerabilities, including missing checks, incorrect meta capabilities, and insecure permission patterns. Upload your plugin to WP HealthKit to identify access control issues before they become security breaches.
Explore how WP HealthKit can help you maintain a secure plugin in your directory or view all our security features in the ecosystem.
Additional Resources
Broader Context and Best Practices
Security vulnerabilities in WordPress plugins don't exist in isolation. Each vulnerability represents a potential entry point that attackers chain together to achieve broader compromise. A seemingly minor issue like improper input validation can escalate when combined with a privilege escalation flaw, turning a low-severity finding into a critical breach. This interconnected nature of security weaknesses is why comprehensive auditing matters so much. Rather than checking individual items in isolation, modern security analysis examines how different components interact and where those interactions create unexpected attack surfaces that manual review would miss entirely.
The WordPress plugin ecosystem's open-source nature creates both strengths and challenges for security. Open code allows community review, which catches many issues early. However, it also means attackers can study source code to find exploitable patterns before patches are released. This asymmetry makes proactive security testing essential rather than reactive. Developers who integrate automated security scanning into their development workflow catch vulnerabilities during development, long before code reaches production. The cost of fixing a security issue during development is orders of magnitude lower than addressing it after a public disclosure or active exploitation.
Understanding the attacker's perspective transforms how developers approach security. Attackers don't think in terms of individual functions or classes. They think in terms of data flows, trust boundaries, and privilege transitions. When data crosses from an untrusted context like user input into a trusted context like a database query, that boundary is where vulnerabilities emerge. By mapping these trust boundaries in your plugin architecture, you can systematically identify where validation, sanitization, and authorization checks are needed. This threat modeling approach is far more effective than trying to remember individual security rules for every function call.
WordPress powers over forty percent of the web, making it the single largest target for automated attacks. Plugin vulnerabilities are the primary vector for these attacks, with Patchstack reporting thousands of new plugin vulnerabilities each year. The scale of the WordPress ecosystem means that even a vulnerability affecting a relatively obscure plugin can impact hundreds of thousands of sites. This reality underscores why every plugin developer has a responsibility to take security seriously, regardless of their plugin's install base. Automated security testing with tools like WP HealthKit makes this responsibility manageable.
Frequently Asked Questions
Can I check capabilities in theme templates?
Yes, you can and should check capabilities in templates if they're handling user input or sensitive operations. However, it's generally better to handle sensitive logic in plugin code rather than themes, as plugins are more appropriate for managing access control.
What's the difference between current_user_can() and user_can()?
current_user_can() checks capabilities for the currently logged-in user. user_can() takes a user object or ID as the first parameter and checks capabilities for that specific user. Use current_user_can() in most cases; use user_can() when checking someone else's capabilities.
How do I check if a user has multiple required capabilities?
You can use logical operators: if ( current_user_can( 'edit_posts' ) && current_user_can( 'publish_posts' ) ). This requires the user to have both capabilities.
What happens if I check a capability that doesn't exist?
If you check a capability that doesn't exist, current_user_can() returns false. This is actually safe from a security perspective (it denies access), but it could mask bugs in your code. Make sure you're checking capabilities that actually exist.
Can custom roles have custom capabilities?
Yes, you can create custom roles and assign any capabilities to them using add_role() and add_cap(). This is how plugins extend the default WordPress role and capability system.
How do I audit which users have a specific capability?
You can loop through users and check their capabilities: foreach ( get_users() as $user ) { if ( user_can( $user->ID, 'capability_name' ) ) { ... } }. However, this is slow for large sites.
Proper capability checks are the foundation of access control in WordPress. By understanding the difference between roles and capabilities, implementing meta capabilities correctly, and checking permissions consistently throughout your code, you'll build plugins that are resistant to privilege escalation attacks.
Make WordPress user role capability checks a core part of your security review process. Scan your plugin now with WP HealthKit to ensure your access control implementation is bulletproof.
Visit the WP HealthKit leaderboard to see how other plugins score on security, and discover areas where you can improve your own security posture.