Pull valid WordPress logins with one curl loop. Here's the fix.

php dev.to

I don't need a password to learn who runs your WordPress site. I need a for loop and about four seconds.

That's not an exploit. Every one of those requests hit a documented endpoint that ships enabled on a default WordPress install. Here's the attack from the other side of the keyboard, then the file that stops it.

The attack, start to finish

WordPress has exposed a REST API under /wp-json/ since 4.7 (December 2016). Most of it earns its keep. One endpoint doesn't, at least not for the public:

curl -s https://example.com/wp-json/wp/v2/users
Enter fullscreen mode Exit fullscreen mode

On a stock install, that returns a JSON array of every author who has published something. Three fields per user do the work:

[{"id":1,"name":"jemee","slug":"jemee","link":"https://example.com/author/jemee/"}]
Enter fullscreen mode Exit fullscreen mode

The slug is the prize. WordPress derives it from the login username, and unless someone changed it by hand, slug is the name you type on wp-login.php. So I don't guess whether admin or jemee exists. The API tells me.

Even if the collection endpoint is partly filtered, I can usually still walk IDs one at a time and pull the slug out of each:

for i in $(seq 1 20); do
  curl -s "https://example.com/wp-json/wp/v2/users/$i" | jq -r '.slug // empty'
done
Enter fullscreen mode Exit fullscreen mode

Twenty requests, twenty potential logins, nothing but curl and jq.

Two more doors to the same list

Kill the REST endpoint and stop there, and you've bolted the front door while two side doors stay open:

  • Author archives. https://example.com/?author=1 301-redirects to /author/realusername/. The login name lands right in the URL. Increment and repeat.
  • oEmbed. https://example.com/wp-json/oembed/1.0/embed?url=<post-url> returns an author_name for whatever post I point it at.

Same usernames, three different paths.

Why a username is worth stealing

The pushback I always get: "so what, the username isn't the password."

Right, but a login is two secrets and I just got one of them for free. That turns a two-unknown problem into a one-unknown problem, which is exactly the shape automated attacks want:

  • Targeted brute force. Username locked, so the cracker only iterates the password. Orders of magnitude faster against a confirmed account.
  • Credential stuffing. I've got breach dumps with billions of email/password pairs. A confirmed valid username tells me which reused-password lists are worth firing.
  • Spear-phishing. Real names and roles make a fake "verify your account" mail land on the one person who can approve it.
  • Admin targeting. ID 1 is almost always the founding admin. That's the account I start with.

None of this guarantees a break-in. It just deletes the easy half of the work and bumps you up the target list.

The fix: one must-use plugin

You don't need a security plugin for this. Drop a file at wp-content/mu-plugins/security.php (must-use plugins load automatically and survive a theme switch) and paste these four filters in.

1. Restrict the users endpoint to authenticated requests

<?php
// Block REST API user enumeration for logged-out visitors
add_filter( 'rest_endpoints', function ( $endpoints ) {
    if ( is_user_logged_in() ) {
        return $endpoints; // editor + integrations keep full access
    }
    if ( isset( $endpoints['/wp/v2/users'] ) ) {
        unset( $endpoints['/wp/v2/users'] );
    }
    if ( isset( $endpoints['/wp/v2/users/(?P<id>[\d]+)'] ) ) {
        unset( $endpoints['/wp/v2/users/(?P<id>[\d]+)'] );
    }
    return $endpoints;
} );
Enter fullscreen mode Exit fullscreen mode

That is_user_logged_in() guard is the line most Stack Overflow snippets drop. Leave it out and you also break Gutenberg for your own editors, since the block editor is an API client too.

2. Redirect ?author=N before it leaks

// Block ?author=N author enumeration on the front end
add_action( 'template_redirect', function () {
    if ( is_admin() ) {
        return;
    }
    if ( isset( $_GET['author'] ) && is_numeric( $_GET['author'] ) ) {
        wp_safe_redirect( home_url(), 301 );
        exit;
    }
} );
Enter fullscreen mode Exit fullscreen mode

3. Strip the author out of oEmbed

// Remove author name/URL from oEmbed responses
add_filter( 'oembed_response_data', function ( $data ) {
    unset( $data['author_name'], $data['author_url'] );
    return $data;
} );
Enter fullscreen mode Exit fullscreen mode

4. Stop the login form from confirming usernames

Default WordPress says "the password you entered for the username X is incorrect," which quietly confirms X exists. Make it generic:

// Generic login error so failures don't confirm valid usernames
add_filter( 'login_errors', function () {
    return 'Login failed. Check your credentials and try again.';
} );
Enter fullscreen mode Exit fullscreen mode

Before and after

curl -s https://example.com/wp-json/wp/v2/users
Enter fullscreen mode Exit fullscreen mode

Logged out, the endpoint now answers with rest_no_route (or a 401, depending on how you filter). Logged in, your dashboard and the block editor behave exactly as before, since the filter only strips routes for unauthenticated requests.

When you should NOT do this

Don't cargo-cult the "disable the REST API" advice. If something external actually reads /wp-json/, wholesale blocking will break it:

  • A headless front end (Next.js, Nuxt, Astro) pulling content over the API.
  • The WordPress mobile app or a third-party integration.
  • A plugin that does public, unauthenticated API reads.

The snippet above threads that needle: it only removes the users routes for logged-out requests, so your posts and media endpoints stay reachable. If you run headless and genuinely need public author data, keep the endpoint but change the slug to something that isn't the login name.

TL;DR

  • /wp-json/wp/v2/users, ?author=N, and oEmbed all leak valid WordPress usernames to anyone.
  • A username is half a login. It powers brute force and credential stuffing.
  • One mu-plugin with four filters closes all three vectors without breaking Gutenberg.
  • Only disable public API access if nothing external depends on it. Otherwise lock down just the users routes.

Go run curl -s https://yoursite.com/wp-json/wp/v2/users right now. If you get a name back, you've got fifteen minutes of work to do.

Source: dev.to

arrow_back Back to Tutorials