By default, anyone can read the WordPress REST API without logging in. A request to /wp-json/wp/v2/users returns the slugs of everyone who has published a post, which is often their login name. /wp-json/wp/v2/posts and /wp-json/wp/v2/pages return your content, custom fields that are registered with show_in_rest, and draft-free metadata to anyone who asks. If your site doesn’t run a headless front end, nobody outside your team needs that.
This snippet restricts the WordPress REST API to authenticated users. Logged-out requests get a clean 401 JSON error. The block editor, application passwords and anything else that authenticates properly keep working. You also get an allowlist for the few routes that guests really do need, such as oEmbed or a contact form endpoint.
The snippet
Put this in a small plugin, an mu-plugin, your theme’s functions.php, or a Scripts Organizer PHP block set to run everywhere.
/**
* Restrict the WordPress REST API to authenticated users,
* with an allowlist of routes that stay public.
*/
function dpc_rest_public_routes(): array {
$routes = [
'/oembed/1.0', // Lets other sites embed your posts.
];
/**
* Route prefixes that logged-out visitors may still call.
*
* @param string[] $routes e.g. '/oembed/1.0', '/wc/store', '/contact-form-7/v1'.
*/
return (array) apply_filters( 'dpc_rest_public_routes', $routes );
}
function dpc_rest_route_is_public( string $route ): bool {
$route = '/' . trim( $route, '/' );
foreach ( dpc_rest_public_routes() as $prefix ) {
$prefix = '/' . trim( (string) $prefix, '/' );
if ( $route === $prefix || str_starts_with( $route, $prefix . '/' ) ) {
return true;
}
}
return false;
}
add_filter( 'rest_pre_dispatch', 'dpc_rest_require_login', 10, 3 );
function dpc_rest_require_login( $result, $server, $request ) {
// Another filter already answered, or the request is authenticated.
if ( null !== $result || is_user_logged_in() ) {
return $result;
}
if ( dpc_rest_route_is_public( $request->get_route() ) ) {
return $result;
}
return new WP_Error(
'dpc_rest_login_required',
__( 'The REST API on this site is only available to logged-in users.', 'dpc' ),
[ 'status' => rest_authorization_required_code() ]
);
}
How it works
rest_pre_dispatchruns before WordPress hands any request to a route’s callback, and it gets the fullWP_REST_Request. That lets the snippet check the route, which the more commonrest_authentication_errorsfilter can’t see without parsing the URL itself.- Authentication has already happened by this point. Cookie logins with a valid nonce, application passwords and any auth plugin have set the current user, so
is_user_logged_in()gives the real answer. - If the user isn’t logged in and the route doesn’t match an allowlisted prefix, the snippet returns a
WP_Error.rest_authorization_required_code()turns that into a401for logged-out visitors. - Matching uses whole path segments.
/wc/storeallows/wc/store/v1/cartbut does not allow/wc/storefront. - Route callbacks never run, so a blocked request costs almost nothing.
Customise it
Add the routes your front end calls as a guest. Use the namespace as the prefix:
add_filter( 'dpc_rest_public_routes', function ( array $routes ): array {
$routes[] = '/wc/store'; // WooCommerce cart and checkout blocks.
$routes[] = '/contact-form-7/v1'; // Contact Form 7 submissions.
return $routes;
} );
To allow only some roles instead of every logged-in user, replace is_user_logged_in() with a capability check such as current_user_can( 'edit_posts' ). Subscribers and customers will then get a 403 instead, because rest_authorization_required_code() returns 403 for users who are logged in.
Gotchas
- Guest-facing blocks break silently. The WooCommerce cart and checkout blocks, many form plugins, search-as-you-type widgets and some analytics plugins call the REST API from the browser for logged-out visitors. Open your key pages in a private window, check the browser’s Network tab for
401responses, and allowlist those namespaces. - Your own JavaScript needs a nonce. If a logged-in user’s request has no
X-WP-Nonceheader (or no_wpnonceparameter), core treats it as logged out on purpose, to prevent CSRF, so this snippet blocks it. Send a nonce created withwp_create_nonce( 'wp_rest' ), or usewp.apiFetch, which adds one for you. - Headless or app front ends have to authenticate, for example with application passwords over HTTPS, or you have to allowlist the routes they read.
- The
/wp-json/discovery link in the page head stays. It reveals nothing once the routes return 401. - Server-side code that calls
rest_do_request()while nobody is logged in, such as a cron job, is blocked too. Allowlist that route or switch to a user withwp_set_current_user()for that job.
For more hardening along the same lines, disable XML-RPC, which is the other public API many sites never use, and consider restricting wp-admin and login to your IP address.
Snippet poll. Should this snippet be in the Scripts Organizer library for one-click import? 10 votes put it there.
0 of 10 votes