---
title: "WordPress Capabilities and Roles, Read From the Source"
url: https://adityaarsharma.com/wordpress-capabilities-and-roles-mapped/
date: 2026-09-23
modified: 2026-09-03
lang: en
author: "Aditya Sharma"
description: "Administrator holds 61 capabilities, editor 34, subscriber 2. And edit_post is not stored anywhere, because map_meta_cap computes it per post at check time."
categories:
  - "Cyber Security"
  - "WordPress"
image: https://adityaarsharma.com/wp-content/uploads/2026/09/6a67a6ef-5210-4393-af89-8038cf211839_2912x1632-1024x574.png
word_count: 1950
---

# WordPress Capabilities and Roles, Read From the Source

There is no row in your database that says a user can `edit_post`. There is no row that says they cannot.

The capability exists only as a string passed into a switch statement, and it is resolved fresh on every check against a specific post ID.

![Administrator holds 61 of them.](https://adityaarsharma.com/wp-content/uploads/2026/09/6a67a6ef-5210-4393-af89-8038cf211839_2912x1632-scaled.png)That is the single fact that explains most of the confusion about WordPress permissions, and it is worth reading the code that produces it. Everything below is from WordPress 7.1, downloaded from wordpress.org on 3 September 2026. `api.wordpress.org/core/version-check/1.7/` reports 7.1 as the current release.

On this page

- What each default role is actually granted- The roles live in one option, and it is autoloaded- Why edit_post is not stored anywhere- do_not_allow is a name, not a flag- Three capabilities that constants can take away- Building a least-privilege role- What does not work- If an agent is going to use this role- The one thing to do- Resources
## What each default role is actually granted
The roles are built once, at install, by `populate_roles()` in `wp-admin/includes/schema.php` line 719. It calls eight versioned functions in order, each adding what a given WordPress release introduced: `populate_roles_160()`, `_210()`, `_230()`, `_250()`, `_260()`, `_270()`, `_280()` and `_300()`.

Nothing has been added since `populate_roles_300()`, which corresponds to WordPress 3.0. Every capability a default role holds in 7.1 was decided by 2010.

### The real per-role totals
I replayed every `add_cap()` call across those eight functions to get the real per-role totals. Counting the deprecated `level_0` through `level_10` caps, which are still granted:

- **administrator**: 61 capabilities, of which 50 are named and 11 are levels- **editor**: 34, of which 26 are named- **author**: 10, of which 7 are named- **contributor**: 5, of which 3 are named- **subscriber**: 2, of which 1 is named![Named capabilities per default WordPress role](https://adityaarsharma.com/wp-content/uploads/2026/09/8833a61c-1fe5-4d62-9ea3-0e8acc30bab4_2912x1632-scaled.png)Replayed from the eight populate_roles_*() functions in wp-admin/includes/schema.php.Subscriber holds exactly `read` and `level_0`. Contributor adds `edit_posts` and `delete_posts`. That is the entire difference between the two roles: two strings.

### Author, the one that matters for automation
Author is where it gets interesting for anything automated. The full named set is `upload_files`, `edit_posts`, `edit_published_posts`, `publish_posts`, `delete_posts`, `delete_published_posts`, `read`. Seven strings, and one of them is `upload_files`, which is the only file-writing capability below editor.

Editor gains the `_pages` and `_others` and `_private` variants, plus `moderate_comments`, `manage_categories`, `manage_links` and `unfiltered_html`. It has no capability that touches plugins, themes, users, options or the dashboard.

### The gap between editor and administrator
Administrator adds all of those at once: `activate_plugins`, `install_plugins`, `update_plugins`, `delete_plugins`, `edit_plugins`, the same five for themes, `edit_files`, `manage_options`, `edit_users`, `create_users`, `delete_users`, `promote_users`, `list_users`, `remove_users`, `update_core`, `import`, `export`, `edit_dashboard`, `unfiltered_upload` and `switch_themes`.

The jump from editor to administrator is 27 named capabilities, and there is nothing in between. That gap is the whole problem with role design in WordPress: the moment someone needs one administrator capability, the standard answer hands them all 27.

![27 named capabilities separate editor from administrator](https://adityaarsharma.com/wp-content/uploads/2026/09/fbcec888-9521-4567-a228-357c22bfd2a2_2400x2400.png)Counted from populate_roles_*() in wp-admin/includes/schema.php.
## The roles live in one option, and it is autoloaded
The last thing `populate_roles()` does is `update_option( $wp_roles->role_key, $wp_roles->roles, true )`, inside an `if ( $original_use_db )` guard.

`$role_key` is `{prefix}user_roles`. That boolean `true` means the entire role and capability structure for the site is one serialized array, loaded on every request.

On a site with a membership plugin that has generated dozens of roles, it is often one of the larger autoloaded rows, and it bypasses the size guard that WordPress 6.6 added, for reasons I worked through in [the autoload audit](https://adityaarsharma.com/wordpress-options-table-autoload-audit/).

### Per-user capabilities live somewhere else
Per-user capabilities are separate. They are user meta under `{prefix}capabilities`, and `WP_User` merges the role's caps with the user's own into `$this->allcaps`. A capability granted directly to a user is invisible in any role editor that reads the roles option.

## Why edit_post is not stored anywhere
`map_meta_cap()` lives in `wp-includes/capabilities.php` line 45. Its whole job is turning a question about an object into a list of strings to look up in `allcaps`.

The code reference states it plainly: this function does not check whether the user has the required capabilities, it just returns what the required capabilities are.

Here is the core of the `edit_post` branch, once the post has been fetched and the post type resolved:

`if ( $post->post_author && $user_id === (int) $post->post_author ) {
if ( in_array( $post->post_status, array( 'publish', 'future' ), true ) ) {
$caps[] = $post_type->cap->edit_published_posts;
} elseif ( 'trash' === $post->post_status ) {
// see below
} else {
$caps[] = $post_type->cap->edit_posts;
}
} else {
$caps[] = $post_type->cap->edit_others_posts;
}`The trash branch reads `_wp_trash_meta_status` from post meta and applies the same test to it.

### What this means for a contributor
![WordPress Developer Resources page for map_meta_cap()](https://adityaarsharma.com/wp-content/uploads/2026/09/36ad8870-36a6-47bf-8826-a198b1dcda0a_2880x1800-scaled.png)developer.wordpress.org, map_meta_cap(). Screenshot taken 3 September 2026.Read what that means for a contributor. A contributor holds `edit_posts` but not `edit_published_posts`. On their own draft, `edit_post` maps to `edit_posts` and they pass.

The moment that draft is published, the same check on the same post by the same user maps to `edit_published_posts` and they fail. Nothing about the user changed.

The post status changed, and the mapping is computed from the post.

### The trash branch almost nobody knows
The trash branch is the detail almost nobody knows: a trashed post is evaluated against the status it had *before* it was trashed, read out of the `_wp_trash_meta_status` post meta. Trashing a published post does not hand editing rights back to its author.

The other half of the function is worth knowing about too. If you call `current_user_can( 'edit_post' )` with no post ID, WordPress does not guess.

Since 6.1 it fires `_doing_it_wrong()` and then appends `do_not_allow`, which fails closed. Same for `delete_post`. If your plugin has a capability check that mysteriously always fails, look for a missing second argument.

## do_not_allow is a name, not a flag
`do_not_allow` appears more than twenty times in `capabilities.php`. It is not special-cased in `map_meta_cap()` at all. The trick is at the end of `WP_User::has_cap()` in `wp-includes/class-wp-user.php`, and the order is the point.

`$capabilities = apply_filters( 'user_has_cap', ... )` runs first. Then core sets `$capabilities['exist'] = true`, and then it calls `unset( $capabilities['do_not_allow'] )`.

The comments in core say it better than I can: everyone is allowed to exist, and nobody is allowed to do things they are not allowed to do. The function then requires ALL requested caps.

### Why the filter cannot grant it
The `unset()` runs after the `user_has_cap` filter. So a plugin that tries to grant `do_not_allow` through that filter, deliberately or by iterating over every capability string it can find, achieves nothing.

And because the final check requires *all* returned caps, a single `do_not_allow` in the array denies the whole check regardless of what else the user holds.

That is why it is used as the deny token everywhere.

One consequence for multisite: `has_cap()` short-circuits for a super admin near the top, returning true for everything *unless* `do_not_allow` is in the mapped caps. A super admin is not "a user with every capability".

They are a user for whom the capability lookup is skipped entirely.

Newsletter

## Agents in Production

I check the things our industry takes on trust and publish what I actually found, including when it makes my own work look worse. One researched piece a week.

Email address

Get it weekly

Free. One email a week. Unsubscribe in one click, and I do not send anything else.

## Three capabilities that constants can take away
`map_meta_cap()` reads wp-config constants directly, which means a role can hold a capability and still be denied it. Three cases, all in `capabilities.php`: The `unfiltered_html` case appends `do_not_allow` when `DISALLOW_UNFILTERED_HTML` is defined and true.

Its comment says so plainly: disallow it for all users, even admins and super admins.

The second branch is the multisite one. On multisite, anyone who is not a super admin gets `do_not_allow` as well.

### The multisite branch
Note the multisite branch. Editors and administrators are both granted `unfiltered_html` by `populate_roles_160()`, and on multisite neither of them gets it. The role data is unchanged; the mapping denies it.

The file editors are next, and they check two separate things: `edit_files`, `edit_plugins` and `edit_themes` share one case. It appends `do_not_allow` if `DISALLOW_FILE_EDIT` is defined and true, or if `wp_is_file_mod_allowed( 'capability_edit_themes' )` returns false.

And the install and update group, which is gated only on `DISALLOW_FILE_MODS` through `wp_is_file_mod_allowed()`: `update_plugins`, `delete_plugins`, `install_plugins`, `upload_plugins`, `update_themes`, `delete_themes`, `install_themes`, `upload_themes`, `update_core`.

### What is missing from that list
Read that list carefully for what is missing. `activate_plugins` is not in it. It has its own case a few lines below with no file-mod check at all.

So `DISALLOW_FILE_MODS` stops a user installing a plugin and stops them updating one, and does not stop them activating a plugin that is already sitting in the directory.

If your threat model includes an attacker who can write to `wp-content/plugins` by some other route, that constant does not close the loop. I go through the rest of those constants in [the wp-config walkthrough](https://adityaarsharma.com/wp-config-constants-that-change-behaviour/).

## Building a least-privilege role
Here is the practical payoff.

Because roles are just an array of capability strings, and because the meta caps resolve to primitives at check time, you can build a role that does one job and nothing else.

This is what I would give an automation that publishes posts and touches nothing else:

`add_role(
'content_publisher_v1',
'Content Publisher',
array(
'read' => true,
'edit_posts' => true,
'edit_published_posts' => true,
'publish_posts' => true,
'upload_files' => true,
)
);`Run it once. `add_role()` is a no-op if the role already exists, so bump the slug when you change the set.

Deliberately absent: `edit_others_posts`, `delete_posts`, `delete_published_posts`, `manage_categories`, `unfiltered_html` and `moderate_comments`.

### What the role can and cannot do
That role can create, publish and revise its own posts and upload media. It cannot touch anyone else's content, cannot delete anything, and cannot create terms.

Compared against author, which is the closest built-in, it drops both delete capabilities. Compared against editor it drops 21 named capabilities.

Check it before you trust it, against a real post, as the real user:

`# meta caps must be checked against an object, never bare
wp eval --user=42 'var_dump( current_user_can( "edit_post", 1234 ) );'
wp eval --user=42 'var_dump( map_meta_cap( "edit_post", 42, 1234 ) );'`Before those two, `wp role list` confirms the role exists and `wp eval 'var_dump( array_keys( array_filter( get_role("content_publisher_v1")->capabilities ) ) );'` prints exactly what it holds.

That second `wp eval` is the one to keep. It shows you the primitive capabilities the check will actually look up, which is the difference between debugging permissions and guessing at them.

## What does not work

### Removing level_* capabilities
**Removing `level_*` capabilities to tighten a role.** They are deprecated and mostly vestigial, but `WP_User::has_cap()` still translates a numeric argument through `translate_level_to_cap()`, and some old plugins still check levels. Stripping them buys you nothing measurable and can break code you did not write.

### Editing a role and expecting a reset
**Editing a role and expecting it to reset.** `populate_roles()` runs at install. It does not run on update.

A role you modified stays modified through every core upgrade, which is convenient until you inherit a site where somebody granted `manage_options` to editor four years ago and nobody remembers.

**Assuming a custom post type follows these rules.** The mapping only happens when `$post_type->map_meta_cap` is true, which is *not* the default for `register_post_type()`. When it is false, `map_meta_cap()` returns the post type's raw cap and stops.

A CPT registered without `'map_meta_cap' => true` gives you none of the ownership logic above.

### A role is not an authentication boundary
**Treating a restricted role as an authentication boundary.** A capability set limits what the account can do through WordPress. It does nothing about how the credential is transported or how long it stays valid.

Application passwords in particular map to the full user, with a filterable but wide-open default scope, which I picked apart in [the application passwords security model](https://adityaarsharma.com/wordpress-application-passwords-security-model/).

## If an agent is going to use this role
A narrow role is the cheapest guardrail available, because it is enforced by core on every request rather than by your prompt.

The rest of the guardrails, including what to do about the capabilities you cannot remove, are in [the piece on running an agent against production WordPress](https://adityaarsharma.com/guardrails-agent-production-wordpress/), and the surface it will reach through is mapped in [the REST API as an agent surface](https://adityaarsharma.com/wordpress-rest-api-agent-surface/).

One thing to carry over from the code above: the REST API calls the same `map_meta_cap()`. There is no separate permission system for REST.

If your role is correct in wp-admin it is correct over HTTP, and if it is wrong, it is wrong in both.

## The one thing to do
Run `wp eval 'var_dump( array_keys( array_filter( get_role("editor")->capabilities ) ) );'` on your oldest site and compare the output against the 34 above.

Anything extra was added by a plugin or a person, and on a site that has been running for years there is usually something in that list that nobody would grant today.

## Resources
- [map_meta_cap()](https://developer.wordpress.org/reference/functions/map_meta_cap/) in the code reference- [Roles and Capabilities](https://wordpress.org/documentation/article/roles-and-capabilities/) in the WordPress documentation- [wp-admin/includes/schema.php](https://github.com/WordPress/wordpress-develop/blob/trunk/src/wp-admin/includes/schema.php), where populate_roles() lives- [wp-includes/capabilities.php](https://github.com/WordPress/wordpress-develop/blob/trunk/src/wp-includes/capabilities.php) on wordpress-develop- [wp role](https://developer.wordpress.org/cli/commands/role/) and [wp cap](https://developer.wordpress.org/cli/commands/cap/) in the WP-CLI command reference