The WordPress REST API exposes /wp-json/wp/v2/users. By default it returns to any anonymous visitor the list of users who have authored published posts — username (login slug), display name, ID, and sometimes a description. For an attacker, this is the perfect first step in a brute-force campaign: instead of guessing both the username and the password, they only need to guess the password.
Why this matters
An attacker who does not yet know your admin account starts with a GET against /wp-json/wp/v2/users. Within a second they learn that user ID 1, named geffen, is almost certainly the administrator. Now they hit wp-login.php with that login and the top ten large-scale automated attacks. Even with a Cloudflare WAF, the first guess is not blocked — only the rate is throttled. The very first attempt at "Password123!" still reaches origin. WordPress 4.7.1 added filtering for users without posts, but not for users with posts — so any blog where author bylines are visible already exposes at least one admin.
How to detect
Open https://example.com/wp-json/wp/v2/users in a private window while logged out. If you see JSON containing usernames, the leak is active. Try the alternative form too: https://example.com/?rest_route=/wp/v2/users — this bypasses security plugins that filter only on the URL path. Many plugins that "disable REST API" block the first form but not the second.
How to fix
The clean approach is to filter rest_authentication_errors and return 401 for unauthenticated requests to the users endpoint. This does not break the block editor, internal REST calls, or admin-facing UIs because logged-in administrators still receive the data.
// Add as an mu-plugin or in a child theme functions.php
add_filter('rest_authentication_errors', function ($result) {
if (!empty($result)) {
return $result;
}
if (!is_user_logged_in()) {
global $wp;
$route = $wp->request ?? '';
if (preg_match('#wp/v2/users#', $route) || (isset($_GET['rest_route']) && strpos($_GET['rest_route'], 'wp/v2/users') !== false)) {
return new WP_Error('rest_login_required', 'Authentication required', ['status' => 401]);
}
}
return $result;
});
Defense in depth: change user_nicename so it differs from user_login. Even if a future update breaks the filter, the slug exposed will not be the actual login name:
UPDATE wp_users SET user_nicename = 'author-x9k2' WHERE user_login = 'admin';
Common mistakes
Mistake one: blocking /wp-json/ entirely at the web server. This breaks the block editor, Yoast SEO, Contact Form 7, and dozens of other plugins that use REST legitimately. Filtering must be selective to the users endpoint. Mistake two: testing only /wp-json/wp/v2/users and forgetting the ?rest_route= form. Attackers know the bypass. Mistake three: trusting Yoast or RankMath to handle this — neither addresses enumeration by default.
Verifying the fix
In a private window, hit both forms. Both should return 401 with the body {"code":"rest_login_required","message":"Authentication required","data":{"status":401}}. Then log in as admin and confirm the block editor, plugin installation, and settings pages still work. Finally re-run the audit and confirm the rest_users_public check passes.
?author=N — the two together cover both vectors of the same attack.