DEV Community

Calin V.
Calin V.

Posted on Originally published at wpghost.com

Closing the WordPress REST API users route, then checking it with curl

/wp-json/wp/v2/users answers anonymous requests on a default WordPress install. The reply carries an id and a slug for each author.

That slug is the same value WordPress puts in the author archive address, /author/<slug>/.

The REST API Handbook's FAQ is blunt about the obvious fix. It says you should not disable the REST API, because WordPress Admin functionality depends on it. So the work below is narrower: close one route, decide whether to require a login for the rest, then test both with curl.

See what your site answers today

Run these from a machine that isn't logged in to the site:

curl -s -o /dev/null -w "%{http_code}\n" https://example.com/wp-json/wp/v2/users
curl -s -o /dev/null -w "%{http_code}\n" "https://example.com/?rest_route=/wp/v2/users"
Enter fullscreen mode Exit fullscreen mode

The second form matters. The Handbook says sites without pretty permalinks take the route as a rest_route query parameter, and that form also answers on sites that do have pretty permalinks. A block that only matches the /wp-json/ path misses it.

A 200 on either line means the list is public.

List the plugin namespaces

The API index has a namespaces item. Every plugin that calls register_rest_route() adds its own entry there:

curl -s https://example.com/wp-json/ | jq -r '.namespaces[]'
Enter fullscreen mode Exit fullscreen mode

On a WooCommerce site the output includes wc/store/v1 next to wp/v2 and oembed/1.0.

Patchstack's 2026 report counted 11,334 vulnerabilities in the WordPress ecosystem in 2025. 91% of them were in plugins.

Remove the users routes for logged-out visitors

Put this in a small plugin (a file in wp-content/mu-plugins/ works):

<?php
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;
} );
Enter fullscreen mode Exit fullscreen mode

The filter runs inside WordPress after the route is parsed, so /wp-json/ and ?rest_route= both lose the route. Run the two curl lines again. Both should print 404 (the code WordPress returns as rest_no_route).

The block editor still loads the users list for logged-in editors, since the check only applies to anonymous requests.

Requiring a login for every route

The Handbook's own answer for anonymous access is the rest_authentication_errors filter with an is_user_logged_in() check:

<?php
add_filter( 'rest_authentication_errors', function ( $result ) {
    if ( ! empty( $result ) ) {
        return $result;
    }
    if ( ! is_user_logged_in() ) {
        return new WP_Error( 'rest_not_logged_in', 'You are not currently logged in.', array( 'status' => 401 ) );
    }
    return $result;
} );
Enter fullscreen mode Exit fullscreen mode

The first if keeps any earlier authentication result, including an error from another plugin.

WooCommerce's documentation calls the Store API "an unauthenticated API", and the cart and checkout blocks call it as the shopper. With the filter on, /wp-json/wc/store/v1/cart returns a 401 to every visitor.

Test these before shipping it to a live site:

Check Command or action Expected with the login filter
Users route curl -s -o /dev/null -w "%{http_code}\n" https://example.com/wp-json/wp/v2/users 401
Store API cart curl -s -o /dev/null -w "%{http_code}\n" https://example.com/wp-json/wc/store/v1/cart 401, so the cart block breaks
Embeds Paste a post URL into another WordPress site's editor No preview card
Contact form Submit the form in a private window Fails if the plugin posts to a REST route

If any row on that list is a feature you sell with, stay with the users filter alone.

The discovery header still points at the API

The Handbook's discovery page says the REST API adds a Link header to every front-end page:

curl -sI https://example.com/ | grep -i 'api.w.org'
Enter fullscreen mode Exit fullscreen mode

The header reads Link: <https://example.com/wp-json/>; rel="https://api.w.org/", and the same address sits in a <link rel='https://api.w.org/'> element in the page head. If you move the API to a custom base path, run the command again and note which address the header now gives out to every crawler.

Five-minute check

Run the two users-route curl lines against one production site you manage today, before and after the rest_endpoints filter, and keep the output.

Has anyone found a plugin that breaks when only the users routes are removed for logged-out visitors?

Top comments (0)