Block WordPress REST API user enumeration without breaking the admin

By default every WordPress install since 4.7 leaks usernames over a public, unauthenticated REST endpoint. Anyone — no login, no auth header, just a browser — can hit https://yoursite.com/wp-json/wp/v2/users and get a JSON array of every user the site considers “public” (which is anyone who has ever authored a post). Combined with how WordPress login works, leaked usernames cut the brute-force key space in half: the attacker no longer has to guess username + password, just password.

The fix is one rewrite block. The trick is doing it without breaking the WP admin’s own use of the same endpoint when you’re editing posts and managing users.

See what you’re leaking

curl -s https://yoursite.com/wp-json/wp/v2/users | jq '.[].slug'

# "admin"
# "editor-jane"
# "writer-bob"
# ...

If you get usernames back, you’re leaking. Some plugins (Yoast, Wordfence’s premium) hide this; on a default install or a lightly-customized one, it’s wide open.

The naive fix and why it’s wrong

You’ll find tutorials suggesting a flat block:

# DON'T do this
RewriteRule ^/?wp-json/wp/v2/users(/.*)?$ - [F,L,NC]

That works for the leak — but it also blocks the same endpoint when your own admin UI calls it. Editing a post in the block editor uses /wp-json/wp/v2/users internally to populate the author dropdown. Block it for everyone and the editor’s “Author” picker stops working. The Site Health → REST API tests start failing. Some plugin admin pages 500.

So the rule needs to allow the call when the request comes from a logged-in admin and reject it for everyone else.

The right rule

# Block anonymous user enumeration via REST API.
# Allow if either:
#   1. WordPress logged-in cookie is present (any signed-in user), OR
#   2. Authorization header is present (Basic auth, OAuth, JWT plugins, etc.)
RewriteCond %{HTTP_COOKIE} !wordpress_logged_in [NC]
RewriteCond %{HTTP:Authorization} !.+
RewriteRule ^/?wp-json/wp/v2/users(/.*)?$ - [F,L,NC]

Drop that into the vhost’s rewrite section (or the site’s .htaccess if you don’t have vhost-level config). On OpenLiteSpeed, set it inside vhconf.conf just after RewriteEngine on; on Apache or LiteSpeed Enterprise + .htaccess, before the WordPress permalink rules.

Why both checks matter

  • Logged-in cookie covers normal admin browser traffic. WordPress sets wordpress_logged_in_* after a successful login; the rewrite engine can read it via %{HTTP_COOKIE} and let the request pass.
  • Authorization header covers programmatic clients — your own scripts using Application Passwords, OAuth-based integrations, JWT-auth plugins. Without this allowance, anything that authenticates via headers instead of cookies would also get blocked.

Both are necessary signals. The cookie-only version breaks REST API clients; the header-only version breaks the admin UI.

Apply it across many sites at once

sudo python3 - <<'PY'
import os, re, glob

SNIPPET = r"""
# Block anonymous REST user enumeration
RewriteCond %{HTTP_COOKIE} !wordpress_logged_in [NC]
RewriteCond %{HTTP:Authorization} !.+
RewriteRule ^/?wp-json/wp/v2/users(/.*)?$ - [F,L,NC]
"""

for f in sorted(glob.glob("/usr/local/lsws/conf/vhosts/*/vhconf.conf")):
    src = open(f).read()
    if "wp-json/wp/v2/users" in src:
        continue  # idempotent
    rw = re.search(r'rewrite\s*\{[^{}]*?enable\s+(\d)', src, re.S)
    if not rw or rw.group(1) == '0':
        continue
    new = src.replace("RewriteEngine on",
                      "RewriteEngine on\n" + SNIPPET.rstrip(), 1)
    if new != src:
        open(f, "w").write(new)
        print("updated:", os.path.basename(os.path.dirname(f)))
PY

sudo /usr/local/lsws/bin/lswsctrl restart

The "wp-json/wp/v2/users" in src guard makes re-runs safe.

Verify both behaviors

# Anonymous: should now return 403
curl -s -o /dev/null -w "%{http_code}\n" https://yoursite.com/wp-json/wp/v2/users
# 403

# Authenticated via Application Password: should still return 200
curl -s -o /dev/null -w "%{http_code}\n" \
  -u "admin:abcd efgh ijkl mnop qrst uvwx" \
  https://yoursite.com/wp-json/wp/v2/users
# 200

# Logged-in browser hitting it via the admin UI: works
# (Cookie is set automatically; the rule's first condition passes.)

Other endpoints worth thinking about

  • /?author=1, /?author=2, … — the older user-enumeration vector. WordPress’s permalink redirect leaks the username in the redirect target. Mitigated separately, usually with a rewrite that 404s those query strings.
  • /wp-json/oembed/1.0/embed?url=... — leaks post titles and authors via embed previews. Generally harmless but worth knowing about.
  • /wp-sitemap-users-1.xml — WordPress 5.5+ generates a sitemap of users. Filter it via the wp_sitemaps_users_query_args hook, or block the sitemap URL.

The user-enum block is the most leveraged of the three — close it and the brute-force chatter against your /wp-login.php drops noticeably within a week, because the bots can no longer cheaply confirm a username before trying passwords.

One rewrite block, three lines, one Python loop to apply across every site. Worth ten minutes once.

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.