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. Concepts: Role vs capability
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 the role name check appears 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”.
Three ways that bites, all of them seen on live sites:
- You build a custom role with
manage_optionsso a client can reach your settings page.current_user_can('administrator')isfalsefor them. Your own admin page 403s for the user it was built for. - A capability granted directly on the user object, with no role attached, passes every capability check and fails every role check.
- On multisite,
WP_User::has_cap()short circuits for super admins before it ever looks atallcaps, returningtruefor anything that is notdo_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. Creating a custom 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”.
3. Adding capabilities to existing roles
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.
4. Disaster recovery: resetting roles
The version of this script that circulates, and that earlier editions of this article carried, 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 editorIt 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=administratorThat last one is the check worth running on any site you inherit. An administrator count that surprises you is the finding.
5. Security best practices 2026
Prune Administrator, do not just add roles
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.
Enumeration is not hardening, but it is free
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.
Close the file paths with constants, not with capabilities
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 the granular delete and edit distinctions are never created, so a user with edit_books can edit 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.
Check capabilities at the point of decision
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, or WordPress logs a _doing_it_wrong() notice.
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.
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()andadd_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_optionsmeans 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 => trueon 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.






