WP CLI List Admin Users, Including Hidden Admins

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}capabilities meta row for each user directly with $wpdb. pre_user_query filters only change WP_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_by column tells you which: role, direct or role+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 marked hidden: YES.
  • A wp_login hook saves dpc_last_login. It only records logins that happen after you install the snippet, so older accounts show not recorded at first.
  • Warnings go to STDERR, so --format=csv and --format=json output stays clean when you pipe it.

Gotchas

  • You can’t “retrieve” an admin password. People search for this in phpMyAdmin a lot. user_pass holds a one-way hash. Find the account with this command, then reset its password with wp user reset-password or set a new one with wp 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 with wp super-admin list.
  • It reads the stored roles and capabilities. Permissions added at runtime through user_has_cap or map_meta_cap filters 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.

// newsletter

Three tested WordPress snippets a week

Blocks, security, performance, Tailwind, PHP and React. Copy, paste, ship. You’ll also get DPlugins product updates. No spam, and you can unsubscribe anytime.

// newsletter

Three tested WordPress snippets a week

Blocks, security, performance, Tailwind, PHP and React. Copy, paste, ship. You'll also get DPlugins product updates. No spam, and you can unsubscribe anytime.

Click to Copy