The WordPress REST API is enabled for all visitors by default, and while most endpoints require authentication for write operations, the discovery endpoint, user enumeration via /wp/v2/users, and any publicly registered route can leak information or become a target for brute-force and enumeration attacks. Hardening the REST API requires a layered approach: disabling public discovery, requiring authentication for sensitive namespaces, adding request-rate limiting, and optionally restricting access by IP.
Problem: A WordPress site's REST API is open to unauthenticated requests from any IP — scrapers, credential-stuffing bots, and enumeration attacks target /wp-json/wp/v2/users and other sensitive endpoints at scale.
Solution: Restrict the users endpoint by removing it in the rest_endpoints filter. Add IP-based allowlisting in Nginx for the /wp-json/ location block for sensitive routes. Apply rate limiting with limit_req_zone for the entire REST API path. Require authentication on custom routes with a strict permission_callback that returns new WP_Error('rest_forbidden') for unauthenticated requests.
The code below removes the REST API link from the frontend for unauthenticated users, blocks /wp/v2/users enumeration, adds a simple rate-limiter using transients, and shows an Nginx-level IP restriction pattern for admin-only REST namespaces.
and HTTP headers for guests
add_action( 'init', function () {
if ( ! is_user_logged_in() ) {
remove_action( 'wp_head', 'rest_output_link_wp_head', 10 );
remove_action( 'template_redirect', 'rest_output_link_header', 11 );
remove_action( 'xmlrpc_rsd_apis', 'rest_output_rsd' );
}
} );
// 2. Block user enumeration via REST API for unauthenticated requests
add_filter( 'rest_endpoints', function ( array $endpoints ): array {
if ( ! is_user_logged_in() ) {
unset( $endpoints['/wp/v2/users'] );
unset( $endpoints['/wp/v2/users/(?P[\d]+)'] );
}
return $endpoints;
} );
// 3. Require authentication for all REST API requests (optional — breaks Gutenberg if on frontend)
// Use only for REST-only headless setups or wp-admin REST calls
add_filter( 'rest_authentication_errors', function ( $result ) {
if ( ! empty( $result ) ) {
return $result; // existing auth error — pass through
}
if ( ! is_user_logged_in() ) {
return new WP_Error(
'rest_not_logged_in',
'REST API requires authentication.',
[ 'status' => 401 ]
);
}
return $result;
} );
// 4. Rate-limit REST API requests per IP (transient-based, simple)
add_filter( 'rest_pre_dispatch', function ( $result, WP_REST_Server $server, WP_REST_Request $request ) {
$ip = sanitize_text_field( $_SERVER['REMOTE_ADDR'] ?? '' );
$key = 'rest_rate_' . md5( $ip );
$window = 60; // seconds
$max_req = 60; // requests per window
$count = (int) get_transient( $key );
if ( $count >= $max_req ) {
return new WP_Error( 'rest_rate_limited', 'Too many requests.', [ 'status' => 429 ] );
}
if ( $count === 0 ) {
set_transient( $key, 1, $window );
} else {
set_transient( $key, $count + 1, $window );
}
return $result;
}, 10, 3 );
# Nginx: restrict /wp-json/my-plugin/ to specific IPs only
location ~ ^/wp-json/my-plugin/ {
allow 192.168.1.0/24;
allow 203.0.113.42;
deny all;
try_files $uri $uri/ /index.php?$args;
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root/index.php;
}
NOTE: Applying the rest_authentication_errors filter site-wide breaks the block editor for logged-out preview links and REST-dependent plugins like WooCommerce Blocks — scope the authentication requirement to specific namespaces using $request->get_route() and only enforce it for your custom API endpoints.