wp user list --role=administrator answers “who are my admins?” on a healthy site. On a hacked one it can leave someone out. Backdoors often hook pre_user_query to hide the account they created. That account then disappears from the Users screen and from every tool that runs through WP_User_Query, and wp user list is one of those tools. The command also only checks the role. It misses a user who was given manage_options directly while still showing as an Editor.
This snippet adds wp dpc-admins. It reads the capability rows straight from the usermeta table and keeps every account that holds the capability, whether through a role or a direct grant. It then compares that list with what the normal user query returns. Any account the query can’t see is marked hidden: YES. The snippet also starts recording a last-login time, so you can spot an admin account that nobody on the team uses.
The snippet
Save this as wp-content/mu-plugins/dpc-admin-audit.php. Use an mu-plugin so the command still works when the regular plugins are deactivated during a cleanup.
<?php
/**
* Plugin Name: DPC Admin Audit
* Description: Lists every account that can manage the site, read from the database, with a WP-CLI command.
*/
defined( 'ABSPATH' ) || exit;
add_action( 'wp_login', 'dpc_record_last_login', 10, 2 );
function dpc_record_last_login( string $user_login, WP_User $user ): void {
update_user_meta( $user->ID, 'dpc_last_login', time() );
}
/**
* Every user holding $capability, via a role or a direct grant.
*/
function dpc_get_admin_audit( string $capability = 'manage_options' ): array {
global $wpdb;
$all_roles = wp_roles()->role_objects;
$granting_roles = array();
foreach ( $all_roles as $role_name => $role ) {
if ( $role->has_cap( $capability ) ) {
$granting_roles[] = $role_name;
}
}
// Raw rows: pre_user_query filters cannot hide anyone from this.
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT u.ID, u.user_login, u.user_email, u.user_registered, m.meta_value
FROM {$wpdb->users} AS u
INNER JOIN {$wpdb->usermeta} AS m ON m.user_id = u.ID
WHERE m.meta_key = %s
ORDER BY u.ID",
$wpdb->get_blog_prefix() . 'capabilities'
)
);
// What the normal user query (Users screen, wp user list) returns.
$visible_ids = array_map(
'intval',
get_users( array( 'capability' => $capability, 'fields' => 'ID' ) )
);
$audit = array();
foreach ( $rows as $row ) {
$caps = maybe_unserialize( $row->meta_value );
if ( ! is_array( $caps ) ) {
continue;
}
$granted = array_keys( array_filter( $caps ) );
$via_roles = array_intersect( $granted, $granting_roles );
$direct = ! empty( $caps[ $capability ] );
if ( ! $via_roles && ! $direct ) {
continue;
}
$sources = $via_roles ? array( 'role' ) : array();
if ( $direct ) {
$sources[] = 'direct';
}
$last_login = (int) get_user_meta( (int) $row->ID, 'dpc_last_login', true );
$audit[] = array(
'ID' => (int) $row->ID,
'user_login' => $row->user_login,
'user_email' => $row->user_email,
'roles' => implode( ',', array_intersect( $granted, array_keys( $all_roles ) ) ),
'granted_by' => implode( '+', $sources ),
'registered' => $row->user_registered,
'last_login' => $last_login ? wp_date( 'Y-m-d H:i', $last_login ) : 'not recorded',
'hidden' => in_array( (int) $row->ID, $visible_ids, true ) ? 'no' : 'YES',
);
}
return $audit;
}
/**
* List every account that holds a capability, read straight from the database.
*
* ## OPTIONS
*
* [--capability=<capability>]
* : Capability to audit.
* ---
* default: manage_options
* ---
*
* [--format=<format>]
* : Output format.
* ---
* default: table
* options:
* - table
* - csv
* - json
* - yaml
* ---
*
* ## EXAMPLES
*
* wp dpc-admins
* wp dpc-admins --capability=edit_users --format=csv
*/
function dpc_cli_admins( array $args, array $assoc_args ): void {
$capability = sanitize_key( $assoc_args['capability'] ?? 'manage_options' );
$items = dpc_get_admin_audit( $capability );
if ( ! $items ) {
WP_CLI::warning( "No users hold '{$capability}'." );
return;
}
WP_CLI\Utils\format_items( $assoc_args['format'] ?? 'table', $items, array_keys( $items[0] ) );
$hidden = count( wp_list_filter( $items, array( 'hidden' => 'YES' ) ) );
if ( $hidden ) {
WP_CLI::warning( "{$hidden} account(s) are hidden from the normal user query. Check them now." );
}
}
if ( defined( 'WP_CLI' ) && WP_CLI ) {
WP_CLI::add_command( 'dpc-admins', 'dpc_cli_admins' );
}
Using it
# Every account with manage_options, as a table
wp dpc-admins
# Export for a client report, or audit a different capability
wp dpc-admins --format=csv > admins.csv
wp dpc-admins --capability=edit_users
# Core equivalent: role only, and it goes through the filterable user query
wp user list --role=administrator --fields=ID,user_login,user_email
# Passwords are hashed, so reset them instead of trying to read them
wp user reset-password dpc_ghost
wp user delete dpc_ghost --reassign=1
How it works
- First it finds every role that grants the capability, using
wp_roles(). Custom roles count too. - Next it reads the
{prefix}capabilitiesmeta row for each user directly with$wpdb.pre_user_queryfilters only changeWP_User_Query, so they can’t remove anyone from this list. - A user is kept if one of their roles grants the capability or if the capability is set directly on them. The
granted_bycolumn tells you which:role,directorrole+direct. - Then the same capability is looked up through
get_users(). If an account is in the database result but missing from that list, some code is hiding it, and the account is markedhidden: YES. - A
wp_loginhook savesdpc_last_login. It only records logins that happen after you install the snippet, so older accounts shownot recordedat first. - Warnings go to STDERR, so
--format=csvand--format=jsonoutput stays clean when you pipe it.
Gotchas
- You can’t “retrieve” an admin password. People search for this in phpMyAdmin a lot.
user_passholds a one-way hash. Find the account with this command, then reset its password withwp user reset-passwordor set a new one withwp user update. - If a user is flagged
hidden, assume the site is compromised. Deleting the user is not enough. Look for the code that hides it (grep -r pre_user_query wp-content) and remove that as well. - The check is per site. On multisite, run it once per site with
--url=and check network admins separately withwp super-admin list. - It reads the stored roles and capabilities. Permissions added at runtime through
user_has_capormap_meta_capfilters don’t show up here. - It loads one meta row per user. That’s fine as an occasional audit, but don’t call
dpc_get_admin_audit()on page loads on a site with very large numbers of users.
Related: if you create admins in code, see Create admin user programmatically. To get an alert as soon as a new account appears, add Notify Admin When New Account Is Created. To narrow who can reach wp-admin at all, see Lockdown Admin Backend to your IP.