wp-json: WordPress is leaking all your usernames.
A French real estate agency launched a new WordPress site. A few hours later it was compromised. Going through the logs, the specialist found a simple chain: first a GET to /wp-json/wp/v2/users — usernames collected. Then brute force via xmlrpc.php. The password turned out to be the same as the login.
The site was down for several days. The owner had no idea WordPress was publicly handing out a list of all users — it works that way by default, with no configuration required.
Add /wp-json/wp/v2/users to the address of any WordPress site and open it in a browser. If nobody closed it, you’ll see something like this:
[
{
"id": 1,
"name": "admin",
"slug": "admin",
"link": "https://example.com/author/admin/",
"url": "https://example.com"
},
{
"id": 2,
"name": "john.doe",
"slug": "john-doe",
"link": "https://example.com/author/john-doe/"
}
]
No authentication. Just curl — and you have a full list of usernames.
WordPress did this on purpose. Not a bug — a feature. That’s exactly why everyone misses it.
WHY THIS ENDPOINT EXISTS AT ALL
The WordPress REST API got its infrastructure in version 4.4 (December 2015) — base classes and routing. But content endpoints, including /wp/v2/users, only appeared in 4.7 (December 2016). The entire modern WordPress stack depends on this API: the Gutenberg block editor, mobile apps, headless CMS integrations.
That’s why you can’t just disable the REST API entirely — the editor breaks, posts won’t save. The goal isn’t to kill the API, it’s to close the specific endpoint that has no business being public.
Among the dozens of routes there’s one: /wp-json/wp/v2/users. It returns all users who have published posts. Without authentication. By design — to support external applications. But for a typical blog or business site, it’s unnecessary exposure.
WHAT AN ATTACKER ACTUALLY SEES
curl -s https://example.com/wp-json/wp/v2/users | python3 -m json.tool
For each user the response contains: id — numeric database identifier, name — display name, slug — alphanumeric identifier used in author URLs, link — direct link to the author page.
There’s an important distinction a lot of people miss. The field username — the actual login credential — has context edit in the official WordPress documentation. That means it’s not visible to anonymous users. Only authenticated users with editor-level permissions can see it.
But the field slug has context view — it’s open to everyone. And here’s the problem: WordPress generates the slug from the username by default at registration. If the admin hasn’t manually changed the slug, slug equals login. The attacker gets it directly.
Passwords and emails don’t leak. But a username in an attacker’s hands is already half the work for a brute force attack.
And there’s more. The root endpoint /wp-json/ returns a complete map of the entire API without any credentials:
# Which plugins registered their own endpoints
curl -s https://example.com/wp-json/ | grep -o '"namespace":"[^"]*"' | sort -u
From the registered namespaces you read the entire stack: WooCommerce, Yoast SEO, Contact Form 7. The attacker cross-references with the CVE database and picks a vector.
WHY THIS IS DANGEROUS
Here’s the real attack chain from pentest reports:
GET /wp-json/wp/v2/users → collect usernames
GET /wp-json/ → identify installed plugins
→ cross-reference with CVE database
→ brute force with known usernames or targeted exploit
The entire cycle takes under five minutes with the right tooling.
A separate story about security plugins. CVE-2025-4302 showed that “Stop User Enumeration” before version 1.7.3 blocked /wp-json/wp/v2/users — but not /wp-json/wp%2Fv2%2Fusers. Simple URL-encoding of the slashes bypassed the entire protection. A plugin with 50,000 installations wasn’t protecting anything. The correct fix is at the WordPress code level or nginx, not a plugin.
HOW TO CLOSE IT
Method 1: WordPress filter (recommended)
Three lines in a mu-plugin. Gutenberg keeps working — it uses authenticated requests. Only anonymous access is blocked:
cat > /var/www/html/wp-content/mu-plugins/restrict-rest-users.php << 'EOF'
<?php
/**
* Plugin Name: Restrict REST API Users Endpoint
* Description: Blocks unauthenticated access to /wp-json/wp/v2/users
*/
add_filter('rest_endpoints', function($endpoints) {
if (!is_user_logged_in()) {
unset($endpoints['/wp/v2/users']);
unset($endpoints['/wp/v2/users/(?P<id>[\d]+)']);
}
return $endpoints;
});
EOF
Verify:
curl -s https://example.com/wp-json/wp/v2/users
# Expected: {"code":"rest_no_route","message":"No route was found...","data":{"status":404}}
This method closes both /wp-json/wp/v2/users and the alternative path /?rest_route=/wp/v2/users — both are handled by the same PHP-level filter.
Method 2: nginx
If you need the block before PHP — faster and no server load wasted:
location ~* ^/wp-json/wp/v2/users {
deny all;
access_log off;
log_not_found off;
}
# Alternative path via ?rest_route=
# if only works in server{} context, not inside location{}
# Place before location blocks:
if ($arg_rest_route ~* "^/wp/v2/users") {
return 403;
}
nginx -t && systemctl reload nginx
curl -I https://example.com/wp-json/wp/v2/users
# Expected: HTTP/1.1 403 Forbidden
Note: the nginx block kills the endpoint completely — including authenticated requests. Fine for most blogs. If you use plugins that interact with users via REST API, Method 1 is safer.
Close the root endpoint for anonymous users
/wp-json/ hands out a plugin map without authentication. Close it:
add_filter('rest_authentication_errors', function($result) {
if (!is_user_logged_in() && isset($_SERVER['REQUEST_URI']) &&
rtrim(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH), '/') === '/wp-json') {
return new WP_Error('rest_forbidden', 'REST API root restricted.', ['status' => 403]);
}
return $result;
});
Or in nginx:
location = /wp-json/ {
deny all;
access_log off;
log_not_found off;
}
Remove the REST API discovery link from HTML
WordPress adds this to every page:
<link rel='https://api.w.org/' href='https://example.com/wp-json/' />
Bots read it as a signal that the REST API is active. Remove it:
// Add to remove-legacy-links.php
remove_action('wp_head', 'rest_output_link_wp_head', 10);
SECOND VECTOR: ?author=1
The REST API isn’t the only way. By default WordPress redirects ?author=1 to /author/username/, putting the user’s slug right in the URL. Bots iterate through numbers sequentially:
curl -I "https://example.com/?author=1"
# HTTP/1.1 301 Moved Permanently
# Location: https://example.com/author/admin/ ← username exposed
Close it via nginx:
location / {
if ($args ~* "^author=\d+$") {
return 403;
}
}
Or via WordPress:
add_action('template_redirect', function() {
if (is_author() && isset($_GET['author'])) {
wp_redirect(home_url(), 301);
exit;
}
});
COMMON MISTAKES
“I have no public authors — the endpoint is empty.” It’s empty now. Add an author with a post and the data appears. Close it preemptively.
“I changed the display name — the login isn’t visible.” name and slug are different fields. You can change the display name all you want. The slug stays in the author URL and in the endpoint.
“I closed the endpoint — Gutenberg broke.” The rest_endpoints filter with is_user_logged_in() doesn’t touch authenticated requests. If something broke — verify the block is only applied to anonymous users.
“I installed Stop User Enumeration.” CVE-2025-4302 — bypassed by URL-encoding the path. Not a solution.
CHECK YOUR SITE
# Is the endpoint open to anonymous users?
curl -s -o /dev/null -w "%{http_code}" https://example.com/wp-json/wp/v2/users
# 200 = open, 403/404 = closed
# Single user by ID
curl -s -o /dev/null -w "%{http_code}" https://example.com/wp-json/wp/v2/users/1
# Is the alternative path closed?
curl -s -o /dev/null -w "%{http_code}" "https://example.com/?rest_route=/wp/v2/users"
# Does author enumeration work?
curl -sI "https://example.com/?author=1" | grep Location
# What namespaces are exposed — which plugins are visible?
curl -s https://example.com/wp-json/ | grep -o '"namespace":"[^"]*"' | sort -u
# REST API link in HTML?
curl -s https://example.com/ | grep "api.w.org"
CONCLUSION
/wp-json/wp/v2/users is open by default on most WordPress sites. Not because the developers made a mistake — it’s by design for external integrations. But for a typical site it’s unnecessary: an attacker gets usernames, plugin namespaces, and a ready-made map for the attack.
One note: WordPress 6.9.1 (February 2026) introduced additional REST API hardening — according to researchers, combined with security plugins this reduces enumeration success by roughly 80%. But the endpoint still returns data by design — the hardening limits abuse, it doesn’t close access. The mu-plugin filter is still needed regardless.
A filter in a mu-plugin closes the problem without side effects. On top of that — nginx to block before PHP. And remove the discovery link from HTML to drop out of automated scans.
Check right now. The endpoint is probably open.
