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 install | Count |
|---|---|
| Namespaces | 16 |
| Routes | 227 |
| Routes that accept GET | 193 |
| Size of the index itself | 295 KB |
And here’s how those routes are spread across namespaces:
| Namespace | Routes |
|---|---|
wp/v2 (WordPress core) | 132 |
elementor, elementor-one, elementor-ai, elementor-mcp-composer | 53 |
pradipta (my own plugins) | 9 |
wp-site-health | 8 |
contact-form-7 | 6 |
wp-abilities, mcp | 8 |
Others (wp-block-editor, oembed, cptui, batch) | 10 |
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
oembedroutes. - 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/usersand/?rest_route=/wp/v2/users. Checking the query variable catches both. Rules that only match the/wp-jsonpath 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 India | Status | Server wait median |
|---|---|---|
/wp-json/wp/v2/posts with the key (allowed) | 200 | 963 ms |
/wp-json/wp/v2/posts without the key (blocked) | 401 | 1,020 ms |
/wp-json/wp/v2/users without the key (blocked) | 401 | 985 ms |
| A static CSS file (no PHP) | 200 | 604 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
| Approach | Protects | Breaks | Cost of a blocked request | Best for |
|---|---|---|---|---|
| 1. Hide users | Author names | Nothing | Full WordPress load | Every ordinary site |
| 2. Login required | Everything | Forms, checkout, embeds, headless | Full WordPress load | Private intranets |
| 3. Allow-list | All but listed routes | Only what you forget to list | Full WordPress load | Sites that know their plugins |
| 4. Shared secret | All but your front end | Nothing, if the key stays on the server | Full WordPress load | Headless sites |
| 5. Firewall rule | What the rule matches | Depends on the rule | Almost none | High-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.