The WordPress REST API is open by default. That’s deliberate: the block editor, many plugins and every headless front end depend on it. But it also means anyone can ask your site for its posts, pages, media and a list of its authors, and that every plugin you install can add more.

I wanted to know how much is exposed on a real site, so I counted on my own.

What a normal install exposes

The WordPress install behind this site runs a handful of ordinary plugins: Elementor, Contact Form 7, Custom Post Type UI and my own. Its REST index (/wp-json/) lists:

On my installCount
Namespaces16
Routes227
Routes that accept GET193
Size of the index itself295 KB

And here’s how those routes are spread across namespaces:

NamespaceRoutes
wp/v2 (WordPress core)132
elementor, elementor-one, elementor-ai, elementor-mcp-composer53
pradipta (my own plugins)9
wp-site-health8
contact-form-76
wp-abilities, mcp8
Others (wp-block-editor, oembed, cptui, batch)10
Counted from /wp-json/ on my own install, October 2026.

Two things stood out. Beyond core’s 132 wp/v2 routes there were 95 more, and Elementor alone accounted for 53 of them. I hadn’t thought about most of them. And newer namespaces such as wp-abilities and mcp exist so AI tools can work with the site. They’re useful, but they’re more surface to think about.

Most of these routes check permissions properly, so “exposed” doesn’t mean “vulnerable”. But the index tells anyone exactly which plugins you run, and /wp/v2/users lists the people who publish on the site, by name and URL slug. Those are the first things an attacker looks up.

Approach 1: leave it open, but hide the user list

The lightest touch keeps everything working and removes the most commonly abused endpoint:

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;
} );

Logged-in editors still see users, so the block editor’s author picker keeps working. Everything else stays public. It’s a good default for an ordinary WordPress site.

Approach 2: require a login for everything

This is the snippet you’ll find most often, and it’s in WordPress’s own REST API handbook:

add_filter( 'rest_authentication_errors', function ( $result ) {
	if ( true === $result || is_wp_error( $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;
} );

It closes everything to visitors, which is also its problem. Things that break:

  • Contact Form 7. It submits forms through /contact-form-7/v1/, so forms stop working for visitors.
  • WooCommerce checkout blocks. The block-based cart and checkout use the Store API (/wc/store/) for guests.
  • Embeds of your content. Other sites embedding your posts use the oembed routes.
  • A headless front end. Your Next.js or mobile app can’t read anything unless it logs in.
  • Front-end plugin features that load more posts, filter products or search through the API.

Approach 3: allow only the routes you need

You can turn approach 2 around: block everything for visitors except a short list.

add_filter( 'rest_authentication_errors', function ( $result ) {
	if ( is_wp_error( $result ) || is_user_logged_in() ) {
		return $result;
	}

	$route   = '/' . ltrim( $GLOBALS['wp']->query_vars['rest_route'] ?? '', '/' );
	$allowed = array( '/contact-form-7/v1/', '/oembed/1.0/', '/wc/store/' );

	foreach ( $allowed as $prefix ) {
		if ( str_starts_with( $route, $prefix ) ) {
			return $result;
		}
	}

	return new WP_Error( 'rest_forbidden', 'Not available.', array( 'status' => 401 ) );
}, 101 );

Two details matter:

  • Priority 101. WordPress’s own cookie check runs at priority 100. Before that, a visitor with a login cookie but no valid security token can look logged in. Running after it means is_user_logged_in() gives the real answer.
  • Read the route from rest_route. The API answers at both /wp-json/wp/v2/users and /?rest_route=/wp/v2/users. Checking the query variable catches both. Rules that only match the /wp-json path miss the second.

Approach 4: a shared secret for your own front end

On a headless site, the front end needs the whole API, but nobody else does. This site uses approach 3 plus a secret key. The Next.js server sends the key in a header, and WordPress lets those requests through:

$secret   = defined( 'PRADIPTA_API_SECRET' ) ? PRADIPTA_API_SECRET : '';
$provided = $_SERVER['HTTP_X_PRADIPTA_API_KEY'] ?? '';

if ( '' !== $secret && hash_equals( $secret, $provided ) ) {
	return $result; // The Next.js server: let it through.
}
// Next.js, on the server only
fetch(`${process.env.WORDPRESS_API_URL}/wp/v2/posts`, {
  headers: { "X-Pradipta-Api-Key": process.env.WORDPRESS_API_SECRET ?? "" },
});

This only works if the key never reaches the browser, so every request must come from server code. hash_equals compares the strings in constant time, so the key can’t be guessed by timing responses. The result on this site: without the key, /, /wp/v2/posts, /wp/v2/users and the oEmbed route all return 401.

I’ve published this as a free plugin: the WordPress REST API Lock.

Approach 5: block at the server or firewall

Approaches 1–4 all run inside WordPress, and that has a cost I didn’t expect until I measured it:

Request, from IndiaStatusServer wait median
/wp-json/wp/v2/posts with the key (allowed)200963 ms
/wp-json/wp/v2/posts without the key (blocked)4011,020 ms
/wp-json/wp/v2/users without the key (blocked)401985 ms
A static CSS file (no PHP)200604 ms
15–30 requests each. Server wait includes one network round trip of about 276 ms.

A blocked request costs as much as an allowed one. WordPress has to load completely, including every plugin, before the authentication filter can say no. Against a scanner making thousands of requests, the lock keeps your data private but still keeps PHP busy.

A firewall rule answers before PHP starts. With Cloudflare, for example:

(http.request.uri.path contains "/wp-json/wp/v2/users"
  or http.request.uri.query contains "rest_route=/wp/v2/users")
and not http.cookie contains "wordpress_logged_in_"
→ Block

A cookie check isn’t real authentication, because anyone can send a fake cookie. Use it to keep out noise, alongside one of the approaches above, not instead of them. Rate-limiting /wp-json at the firewall is worth doing too.

Side by side

ApproachProtectsBreaksCost of a blocked requestBest for
1. Hide usersAuthor namesNothingFull WordPress loadEvery ordinary site
2. Login requiredEverythingForms, checkout, embeds, headlessFull WordPress loadPrivate intranets
3. Allow-listAll but listed routesOnly what you forget to listFull WordPress loadSites that know their plugins
4. Shared secretAll but your front endNothing, if the key stays on the serverFull WordPress loadHeadless sites
5. Firewall ruleWhat the rule matchesDepends on the ruleAlmost noneHigh-traffic or frequently scanned sites

What I’d do

On an ordinary WordPress site, start with approach 1. It’s five lines and breaks nothing. On a headless site, use approach 4 with a short allow-list, and add a firewall rule if logs show scanners hitting /wp-json.

And whichever approach you choose, open /wp-json/ on your own site once. Counting what your plugins have added is a quick exercise, and it may change what you think needs locking down.