WordPress Roles and Capabilities - Developer Guide

WordPress Roles and Capabilities - Developer Guide

Last verified: October 13, 2026
16 min read
Guide
Full-stack developer
Security auditor

WordPress ships a full access control list and almost nobody uses it. The permission bug we find most often on client sites is not an unpatched plugin, it is a content editor holding the Administrator role because somebody needed them to reorder a menu once. This guide covers what the roles and capabilities API actually does at the source level: where the data lives, why add_role() silently ignores your second call, what a role name check really resolves to, and which capabilities are wider than their name suggests.

One shift does most of the work. Stop naming roles in your conditionals and name the capability the code needs. The rest of this guide is what that costs you in practice.

#1. WordPress roles vs capabilities

A capability is a single permission string: edit_posts, publish_pages, install_plugins. A role is a named bag of them. WordPress stores the bag, evaluates the string.

The default bags are smaller than people assume. Per the WordPress roles and capabilities documentation, Editor holds 26 capabilities and Author holds seven: read, upload_files, edit_posts, edit_published_posts, publish_posts, delete_posts, delete_published_posts. Contributor holds three: read, edit_posts, delete_posts. Contributor cannot upload a file, which is why “just make them a Contributor” ends in a support ticket about the media library.

Editor is the interesting one. It carries unfiltered_html on a single site install, which means an Editor can save a raw <script> tag into post content. On multisite that capability is denied to everyone except super admins, as map_meta_cap() shows:

if ( defined( 'DISALLOW_UNFILTERED_HTML' ) && DISALLOW_UNFILTERED_HTML ) {
    $caps[] = 'do_not_allow';
} elseif ( is_multisite() && ! is_super_admin( $user_id ) ) {
    $caps[] = 'do_not_allow';
} else {
    $caps[] = 'unfiltered_html';
}

What Editor does not carry: edit_theme_options, list_users, edit_users, manage_options, anything plugin related.

#Why does checking a role name seem to work?

Checking a role name is not a syntax error. It is worse, it returns the right answer for the one case you tested.

// Wrong
if ( current_user_can( 'administrator' ) ) { ... }

// Right
if ( current_user_can( 'manage_options' ) ) { ... }

The reason the first line returns true for an administrator is a merge at the end of WP_User::get_role_caps():

$this->allcaps = array();
foreach ( (array) $this->roles as $role ) {
    $the_role      = $wp_roles->get_role( $role );
    $this->allcaps = array_merge( (array) $this->allcaps, (array) $the_role->capabilities );
}
$this->allcaps = array_merge( (array) $this->allcaps, (array) $this->caps );

$this->caps is the raw wp_capabilities user meta, and its keys are role names. So administrator => true lands in allcaps alongside real capabilities and passes the check by accident. The core documentation for current_user_can() puts it as:

“while checking against particular roles in place of a capability is supported in part, this practice is discouraged as it may produce unreliable results”

WordPress Developer Resources, current_user_can()

Three ways that bites, all of them seen on live sites:

  1. You build a custom role with manage_options so a client can reach your settings page. current_user_can('administrator') is false for them. Your own admin page 403s for the user it was built for.
  2. A capability granted directly on the user object, with no role attached, passes every capability check and fails every role check.
  3. On multisite, WP_User::has_cap() short circuits for super admins before it ever looks at allcaps, returning true for anything that is not do_not_allow. A role check there does not discriminate at all.

Capability checks survive all three because the capability is the thing the code actually needs.

#2. How to create a custom role with add_role

Before writing one, check whether the platform already ships it. WooCommerce adds shop_manager and customer on install and, per its roles documentation, Shop Manager can already manage products, orders, coupons and customer accounts. Building a parallel “store manager” duplicates a role that gets updated by someone else’s release cycle.

When you do need one, the shape is this:

add_role(
    'editorial_lead',
    'Editorial lead',
    [
        'read'                 => true,
        'upload_files'         => true,
        'edit_posts'           => true,
        'edit_others_posts'    => true,
        'edit_published_posts' => true,
        'publish_posts'        => true,
        'delete_posts'         => true,
        'moderate_comments'    => true,
    ]
);

Two things about that call are not obvious from the signature.

It writes once and then ignores you. The plugin handbook states that after the first call, the role and its capabilities are stored in the database, and “sequential calls will do nothing: including altering the capabilities list”. Edit the array, redeploy, and nothing changes. Every capability change therefore needs an explicit migration:

const EDITORIAL_LEAD_VERSION = 3;

function wppoland_sync_editorial_lead() {
    if ( (int) get_option( 'wppoland_editorial_lead_version' ) === EDITORIAL_LEAD_VERSION ) {
        return;
    }

    remove_role( 'editorial_lead' );
    add_role( 'editorial_lead', 'Editorial lead', [ /* ... */ ] );

    update_option( 'wppoland_editorial_lead_version', EDITORIAL_LEAD_VERSION );
}
register_activation_hook( __FILE__, 'wppoland_sync_editorial_lead' );
add_action( 'admin_init', 'wppoland_sync_editorial_lead' );

The admin_init copy matters on multisite and on deploys that never fire an activation hook. The version gate is what keeps it off the hot path, and the handbook warns directly that removing and re-adding unconditionally will “degrade performance considerably”.

remove_role() does not touch users. It unsets the key in the options row. Every user whose wp_capabilities meta still says editorial_lead => true keeps that key, WP_Roles::is_role() now returns false for it, so it is never resolved into capabilities. The user is left holding a string that grants nothing. Reassign users before removing a role, or immediately after.

Where the row actually is. WP_Roles::for_site() builds the key as $wpdb->get_blog_prefix( $this->site_id ) . 'user_roles'. On a single site with the default prefix that is wp_user_roles. On subsite 3 of a network it is wp_3_user_roles, which is why a role added on one subsite is invisible on the next. If you want roles defined in code and never written to the database at all, set the $wp_user_roles global in wp-config.php: WordPress then uses it and, in the words of the core reference, “the role option will not be updated or used”.

#Role labels on multilingual sites

The second argument of add_role() is the display label, and WP_Roles::add_role() stores it in the options row exactly as it arrives. Wrap it in __() and what gets stored is the string in whatever language was active at activation time, not the language of whoever opens the users screen later. On display, wp_dropdown_roles() passes the label through translate_user_role(), which translates with the User role context in the default text domain. That covers the core roles, whose strings sit in the WordPress catalogue, and does not cover your plugin’s label. On a multilingual site, keep both slug and label in English and never build logic on the label: the slug is the stable identifier.

#3. How to add capabilities to an existing role

get_role() plus add_cap() is the smaller diff when a default role is nearly right:

$role = get_role( 'editor' );
if ( $role ) {
    $role->add_cap( 'edit_theme_options' );
}

The same version gate from section 2 applies. WP_Role::add_cap() takes exactly two parameters, add_cap( $cap, $grant = true ), and the second one decides whether the capability is granted or explicitly denied, not whether the row is written. There is no third parameter here. The three-argument form belongs to WP_Roles::add_cap( $role, $cap, $grant ), a different class, and that method is what WP_Role::add_cap() delegates to. It assigns the capability and then calls update_option() whenever use_db is true, on every call. So no argument suppresses the write, and running add_cap() on init unguarded means an options write attempt on every request. The version gate is the control, not an argument.

The bigger problem with that specific line is scope. edit_theme_options is the classic “let the Editor manage menus” fix, and on a classic theme it does roughly that: Appearance for Widgets, Menus, Customize and Header. On a block theme it is a different capability. The core documentation now names it as the primary capability WordPress checks when deciding whether a user can reach and manage templates through the Site Editor, and warns that it “is not limited exclusively to the Site Editor and may grant access to other theme-related administrative functionality”.

So the Editor you wanted to give a menu reorder now has the Site Editor: templates, template parts, global styles, navigation. That is a wider grant than switch_themes, which only exposes Appearance and Appearance > Themes.

If a Site Editor role is genuinely what you want, the documentation lists what it takes: edit_theme_options for access and the navigation menu, plus edit_posts, edit_pages and edit_others_posts for template management, read for previewing, upload_files for media.

The failure mode here is quiet, because the Site Editor talks to the REST API. Template and template part endpoints check edit_theme_options; post and page endpoints run their own checks. A role that can open the Site Editor but is missing edit_others_posts gets a panel that loads and then fails to save. Read the network tab for 401 and 403 responses before you start adding capabilities blind. The REST error code names the permission that failed.

#Conditional capabilities with user_has_cap

Some rules do not fit in a role because they depend on the object: an Editor who must not touch posts in one particular category, for example. That is what the user_has_cap filter is for. WP_User::has_cap() applies it after map_meta_cap(), with four arguments: $allcaps (what the user holds), $caps (the primitives the check requires), $args (the requested capability in $args[0], the user ID in $args[1] and, usually, the object ID in $args[2]) and the WP_User object.

add_filter( 'user_has_cap', function ( $allcaps, $caps, $args, $user ) {
    if ( 'edit_post' !== ( $args[0] ?? '' ) || empty( $args[2] ) ) {
        return $allcaps;
    }
    if ( has_term( 'legal', 'category', $args[2] ) && empty( $allcaps['manage_options'] ) ) {
        foreach ( $caps as $cap ) {
            $allcaps[ $cap ] = false;
        }
    }
    return $allcaps;
}, 10, 4 );

Three details in the source decide whether this works. The filter only sees checks that carry the object ID, so current_user_can( 'edit_posts' ) passes straight through without the rule firing. It runs on every capability check, so the early return on the first line is not optional. And on multisite it never reaches super admins: has_cap() returns true for them before the filter is applied, as section 6 shows.

#4. How to reset WordPress roles to default

The version of this script that circulates is a remote privilege escalation:

// Do not ship this.
if ( ! isset( $_GET['reset_roles_secret_key'] ) ) return;
require_once( ABSPATH . 'wp-admin/includes/schema.php' );
populate_roles();

isset() tests that the parameter is present, not that it is correct. Any unauthenticated visitor appending ?reset_roles_secret_key to any URL on the site restores default capabilities, which includes handing back anything you deliberately stripped from Editor or Author.

Use WP-CLI instead. wp role reset runs off the same core function and is authenticated by shell access:

wp role reset --all
wp role reset editor

It also does more than populate_roles() does on its own, and the difference matters when you are debugging. populate_roles() calls populate_roles_160() through populate_roles_300(), and those functions only ever call add_role() and add_cap(). There is no remove_cap() anywhere in them. Called directly, it restores capabilities that were removed and leaves every capability a plugin added. WP-CLI diffs against a clean install on top of that, which is why its output reads like this:

Restored 1 capability to and removed 0 capabilities from 'administrator' role.

Custom roles are untouched by either path. If your site’s problem is a custom role gone wrong, a reset will not fix it.

Inspect before you reset. The state you are debugging is one options row:

wp option get wp_user_roles --format=json | jq '.editor.capabilities'
wp cap list editor
wp user list --field=user_login --role=administrator

That last one is the check worth running on any site you inherit. An administrator count that surprises you is the finding.

#5. How to secure WordPress user roles

#How to audit administrator accounts in WordPress

Least privilege is not a new role, it is a smaller list of administrators. Run wp user list --role=administrator on the sites you maintain. Agency accounts, a departed freelancer and the plugin support login from two years ago are the usual residents.

#Admin username and user enumeration via the REST API

The “never call the user admin” advice predates the installer asking for a username, which it has done since WordPress 3.0. The exposure that actually remains is enumeration: the REST users endpoint returns id, name, url, description, link, slug and avatar_urls without authentication. roles and capabilities are edit context and stay hidden, but slug is the user_nicename, which WordPress derives from the login unless somebody changed it. That is a username list, not a breach, and rate limiting the login form is the control that matters more.

#How to disable file editing with DISALLOW_FILE_EDIT

Removing edit_themes from Administrator is a capability change someone can undo. A constant in wp-config.php is checked inside map_meta_cap() and cannot be granted around:

define( 'DISALLOW_FILE_EDIT', true );

DISALLOW_FILE_EDIT maps edit_files, edit_plugins and edit_themes to do_not_allow. DISALLOW_FILE_MODS goes further and blocks plugin and theme install and update from the admin, and disables the file editor as a side effect. On a site deployed from Git, DISALLOW_FILE_MODS is the honest setting, because updates land through the pipeline anyway.

DISALLOW_UNFILTERED_HTML works the same way and is worth setting on any editorial site where Editors do not need to paste script tags.

#Map meta capabilities on custom post types

register_post_type() defaults map_meta_cap to null, not false. WP_Post_Type::set_props() then flips that null to true when capabilities is empty and capability_type is post or page, kept for back compat with 3.0 behaviour (core ticket #14122), and falls back to false in every other case. A custom capability_type like the one below therefore lands on false unless you say otherwise. Set both arguments:

register_post_type( 'book', [
    'capability_type' => [ 'book', 'books' ],
    'map_meta_cap'    => true,
] );

The array form is not decoration. With a plain string WordPress appends s to build the plural, which gives you storys for story. The register_post_type() reference names the array as the way to pass an alternative plural.

capability_type => 'book' generates the meta capabilities edit_book, read_book, delete_book and the primitives edit_books, edit_others_books, delete_books, publish_books, read_private_books. Six more, read, delete_private_books, delete_published_books, delete_others_books, edit_private_books and edit_published_books, are only generated when map_meta_cap is true, because only map_meta_cap() consumes them. That is the whole reason the flag exists. Without it, map_meta_cap() resolves the edit_book meta capability to the literal string edit_book, with no look at who the author is or whether the book is published, so whoever holds that capability edits everyone’s books.

One more default to watch: create_posts maps to edit_books unless you set it explicitly. If you want a role that can edit existing books but never create one, you have to say so in the capabilities array.

#Where to check current_user_can in handlers and REST routes

The capability check belongs next to the operation, not at the menu. add_menu_page() taking a capability hides a link. It does not protect the handler. Every admin post handler, AJAX callback and REST route needs its own current_user_can(), and REST routes need it in permission_callback rather than inside the handler. Since WordPress 5.5, register_rest_route() logs a _doing_it_wrong() notice when permission_callback is missing, and for a route that really is public the documented value is __return_true.

For anything that acts on a specific object, pass the object: current_user_can( 'edit_post', $post_id ) routes through map_meta_cap() and resolves ownership. current_user_can( 'edit_posts' ) does not, and answers a question about the post type instead of about that post.

#Access audit logs and separate environments

An audit log that tracks logins, role changes and sensitive actions tells you who granted what and when. Plugins such as WP Activity Log cover that job, and you can also build it on WordPress hooks.

Roles on development, staging and production are reviewed separately. A role that is reasonable in development, with extra permissions for debugging, can be dangerous in production.

#6. WordPress capabilities on multisite and in plugins

#Network capabilities on multisite

On WordPress Multisite, capabilities work at two levels: the individual site and the network. Per the roles and capabilities documentation, these ten exist only for the super admin: create_sites, delete_sites, manage_network, manage_sites, manage_network_users, manage_network_plugins, manage_network_themes, manage_network_options, upgrade_network and setup_network.

The surprising part runs the other way. A subsite Administrator loses capabilities it holds on a single site. The same documentation lists update_core, update_plugins, update_themes, install_plugins, install_themes, delete_themes, delete_plugins, edit_plugins, edit_themes, edit_files, edit_users, add_users, create_users, delete_users and unfiltered_html as capabilities only the super admin has on multisite. They are not removed from the role row: map_meta_cap() turns them into do_not_allow under the condition is_multisite() && ! is_super_admin( $user_id ), the same one you saw in section 1 for unfiltered_html. That is why adding install_plugins to a subsite’s Administrator role changes nothing.

WP_User::has_cap() handles the super admin before it looks at allcaps and before it applies the user_has_cap filter: if the result of map_meta_cap() does not contain do_not_allow, it returns true. To take something away from a super admin you filter map_meta_cap and add do_not_allow, not user_has_cap.

To check a permission on another site in the network, WordPress 6.7 introduced current_user_can_for_site( $site_id, $capability ), which replaces current_user_can_for_blog(), now in deprecated.php. The plugin handbook still shows the old name.

#WooCommerce, BuddyPress and bbPress capabilities

Plugins such as WooCommerce, BuddyPress and bbPress add their own capabilities to the system. When you plan the role architecture for a site running them, account for both the native WordPress capabilities and the ones the plugins add.

#Members and User Role Editor for managing roles

The Members and User Role Editor plugins give you a graphical interface for roles and capabilities. For professional development it is still better to manage roles in versioned code, so permission changes go through the same review as any other change.

#Summary

Roles are storage. Capabilities are the decision. Everything in this guide follows from that split:

  • Check capabilities, never role names, because current_user_can('administrator') resolves through a merge of user meta keys and returns false for every custom role you build.
  • Treat add_role() and add_cap() as migrations against a database row, version gated, not as configuration you can edit and redeploy.
  • Read a capability’s real scope before granting it. edit_theme_options means the Site Editor on a block theme, not just the menus screen.
  • Recover with wp role reset --all, never with an unauthenticated query parameter, and remember it leaves custom roles alone.
  • Put map_meta_cap => true on every custom post type that has more than one author.

If you want someone else to run the administrator audit, WPPoland does WordPress security audits that start with exactly the user and capability review described above.

Next step

Turn the article into an actual implementation

This block strengthens internal linking and gives readers the most relevant next move instead of leaving them at a dead end.

Related cluster

Explore other WordPress services and knowledge base

Strengthen your business with professional technical support in key areas of the WordPress ecosystem.

Frequently asked questions

Practical answers to apply the topic in real execution.

What is the difference between a role and a capability in WordPress?#
A capability is one permission string such as edit_posts or manage_options. A role is a named bundle of them stored in the options table. WordPress checks capabilities at the point of decision, so a role only matters as the container that delivers the capability.
Should you check user roles directly in WordPress code?#
No. current_user_can('administrator') happens to work because WP_User::get_role_caps() merges the user's own caps array, where role names sit as keys, into allcaps. It still returns false for a custom role that holds manage_options, so the check locks out the exact user the role was built for.
How do you reset broken WordPress roles?#
Run wp role reset --all with WP-CLI. It calls populate_roles() and then diffs the result, so it both restores removed default capabilities and strips added ones. Calling populate_roles() yourself only adds back what is missing, because the populate_roles_* functions never call remove_cap().
Why does add_role() ignore my new capability?#
add_role() writes to the database once. The plugin handbook is explicit that sequential calls do nothing, including altering the capability list. Store a version number in an option, compare it on activation, and call remove_role() then add_role() when it changes.
Which capability controls the Site Editor in a block theme?#
edit_theme_options is the primary one. WordPress checks it on the template and template part REST endpoints. Full use of the Site Editor usually also needs edit_posts, edit_pages, edit_others_posts, read and upload_files.

Need an FAQ tailored to your industry and market? We can build one aligned with your business goals.

Let’s discuss

Related Articles