Securing the WP REST API: Closing the Backdoor for Bots
Say you’ve spent two weeks building layered spam protection on your contact form. Honeypot fields, timing analysis, JavaScript challenges, maybe even a CAPTCHA. Submissions drop to near zero. You breathe easy.
Then you check your server logs and find 6,000 POST requests sent directly to /wp-json/contact-form-7/v1/contact-forms/123/feedback. No page load. No JavaScript execution. No honeypot triggered. The bot skipped the front end entirely and talked directly to the API.
Every WordPress site with the REST API enabled—meaning every WordPress site since version 4.7—has a public API. Many of these endpoints accept unauthenticated requests by default. And most site administrators don’t even know they exist.
This is a public endpoint that many administrators overlook—a path that can bypass front-end defenses entirely. The front end is the front door with triple deadbolts. The REST API is the side door with the key under the mat.
The Problem: Front-End Defenses Don’t Protect the API
WordPress introduced the REST API as a core feature in December 2016. It was a big deal for developers—a standardized, JSON-based interface for reading and writing WordPress data over HTTP. Headless front ends, mobile apps, and third-party integrations could now communicate with WordPress without scraping HTML or abusing admin-ajax.php.
But the REST API was designed for interoperability, not lockdown. The REST API itself isn’t inherently dangerous—risk arises from how authentication and validation are designed around it. By default, many endpoints are publicly accessible without authentication. Anyone—or anything—can send a GET request to /wp-json/wp/v2/users and retrieve a list of usernames. If a plugin doesn’t implement proper permission checks, POST requests to custom endpoints are possible too.
Bots figured this out quickly.
What Bots See When They Look at Your Site
Many bots don’t load your contact page. In most cases they don’t render CSS or execute JavaScript. They send a single HTTP request:
GET /wp-json/ HTTP/1.1
Host: example.com
WordPress returns a complete index of every registered REST route—methods, endpoint URLs, accepted parameters. It’s like handing out a free map of your entire API surface to anyone who asks.
From there, a bot knows exactly which endpoints accept POST data, what fields are expected, and how the request should be formatted. No guessing. No browser required.
# Enumerate all registered routes on a WordPress site
curl -s https://example.com/wp-json/ | jq '.routes | keys[]'
This returns every REST route the site exposes. A typical WordPress install with a few plugins lists 50–200 endpoints. Each one is a potential attack surface.
The Contact Form 7 Example
Contact Form 7 registers its own REST endpoint for form submissions:
POST /wp-json/contact-form-7/v1/contact-forms/{id}/feedback
This is the endpoint the front-end JavaScript calls when a user clicks Submit. It’s also the endpoint a bot calls when it wants to bypass every defense you’ve applied to the front end. The request is simple:
curl -X POST https://example.com/wp-json/contact-form-7/v1/contact-forms/123/feedback \
-F "your-name=Bot McBotface" \
-F "your-email=spam@example.com" \
-F "your-message=Buy cheap backlinks at http://spam-domain.com" \
-F "_wpcf7=123" \
-F "_wpcf7_version=6.0" \
-F "_wpcf7_unit_tag=wpcf7-f123-o1"
No page load. No JavaScript. No honeypot field rendered. No CAPTCHA served. The bot sends a raw HTTP POST with the correct field names, and Contact Form 7 processes it like any normal submission.
Every client-side defense you added—honeypot fields, JavaScript timers, mouse movement trackers—doesn’t function against this path. Bots often don’t load the page at all, and these defenses exist only in the HTML that the bot never requested.
Technical Deep Dive: How the REST API Authentication Model Works
To fix the problem, you need to understand how WordPress decides who gets to do what via the REST API.
The Permission Callback System
Every WordPress REST route can define a permission callback—a function that runs before the endpoint logic and returns true or false. If it returns false, the request is rejected with a 403 status.
A properly secured custom endpoint looks like this:
add_action( 'rest_api_init', function () {
register_rest_route( 'myplugin/v1', '/data', array(
'methods' => 'GET',
'callback' => 'myplugin_get_data',
'permission_callback' => function ( WP_REST_Request $request ) {
return current_user_can( 'edit_posts' );
},
) );
} );
The permission_callback here checks whether the requester is a logged-in user with the edit_posts capability. Anonymous requests fail. Subscriber-level accounts fail. Only editors and above get through.
The problem is: many plugins set the permission callback to __return_true (allow everyone) or omit it entirely. Since WordPress 5.5, routes without a permission callback trigger a _doing_it_wrong notice in the debug log, but the request itself isn’t blocked. The endpoint keeps working.
WordPress Nonces: Not What You Think They Are
The word “nonce” means “number used once,” but WordPress nonces are not true nonces. They’re time-limited HMAC tokens tied to a specific user session and action. A WordPress nonce is valid for 24 hours (two 12-hour tick windows) and can be reused within that period.
For REST API requests, WordPress uses the X-WP-Nonce header or the _wpnonce parameter:
// Send an authenticated REST request from front-end JavaScript
fetch( '/wp-json/wp/v2/posts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': wpApiSettings.nonce // Generated by wp_localize_script()
},
body: JSON.stringify({ title: 'My Post', status: 'draft' })
} );
The nonce ties the request to a logged-in user session. If a request doesn’t include a valid nonce, WordPress treats it as an unauthenticated (anonymous) request. It doesn’t reject it—it just sets the current user to 0.
This is where many people get confused. No nonce doesn’t mean 403. It means the request is processed as if nobody is logged in. Whether that’s a problem depends entirely on whether the endpoint’s permission callback checks for authentication.
Available Authentication Methods
WordPress supports multiple authentication methods for the REST API, each suited to different use cases:
| Method | Mechanism | Best For |
|---|---|---|
| Cookie + Nonce | Session cookie + X-WP-Nonce header |
Same-domain front-end JS |
| Application Passwords | Base64-encoded username:password in Authorization header |
External apps, mobile clients |
| OAuth 2.0 (via plugin) | Bearer token in Authorization header |
Third-party integrations |
| JWT (via plugin) | JSON Web Token in Authorization header |
Headless/decoupled front ends |
For protecting form submission endpoints from bots, Cookie + Nonce is the most relevant method, but it only works for logged-in users. And contact form visitors are almost certainly not logged in.
This is the fundamental contradiction: you need an endpoint that accepts submissions from anonymous visitors, but you also need to verify that those submissions are legitimate.
The Solution: Secure the REST API Layer by Layer
There’s no single fix. Securing wp rest api security requires a layered approach—restrict what you can, validate what you can’t restrict, and monitor everything.
Layer 1: Disable Route Index for Unauthenticated Users
The route index (/wp-json/) is a free reconnaissance tool for bots. You can hide it from anonymous visitors without breaking authenticated functionality:
/**
* Remove the REST API route index for unauthenticated requests.
* Authenticated users (with a valid nonce) can still see the full index.
*/
add_filter( 'rest_authentication_errors', function ( $result ) {
// If a previous authentication check already failed, pass through.
if ( is_wp_error( $result ) ) {
return $result;
}
// Allow logged-in users full access to all endpoints.
if ( is_user_logged_in() ) {
return $result;
}
// Block unauthenticated access to the REST API index.
$rest_route = $GLOBALS['wp']->query_vars['rest_route'] ?? '';
if ( empty( $rest_route ) || $rest_route === '/' ) {
return new WP_Error(
'rest_forbidden',
__( 'REST API index is not available.' ),
array( 'status' => 403 )
);
}
return $result;
} );
This prevents bots from discovering your endpoint structure. It doesn’t block access to known endpoints—a bot that already knows the CF7 URL can still reach it—but it eliminates the free enumeration step.
Layer 2: Restrict the Users Endpoint
The /wp-json/wp/v2/users endpoint is one of the most abused discovery paths. It leaks usernames that can be used for brute-force login attacks. Lock it down:
/**
* Restrict the users endpoint to authenticated requests only.
*/
add_filter( 'rest_endpoints', function ( $endpoints ) {
if ( ! is_user_logged_in() ) {
// Remove /wp/v2/users routes for anonymous users.
unset( $endpoints['/wp/v2/users'] );
unset( $endpoints['/wp/v2/users/(?P<id>[\\d]+)'] );
}
return $endpoints;
} );
Some plugins depend on the users endpoint. Test this change in a staging environment before deploying to production. Note that the rest_endpoints filter removes route definitions from the array, but direct URL access may still pass through in some environments. For more robust protection, combine this with rest_authentication_errors or rest_pre_dispatch filters.
Layer 3: Add Custom Permission Callbacks to Plugin Endpoints
If you’re creating custom REST endpoints—or need to harden an existing plugin’s endpoints—you can hook into rest_pre_dispatch to apply additional validation:
/**
* Require a valid referer and custom token for specific REST routes.
* Adds a server-side validation layer that raw cURL requests can't satisfy.
*/
add_filter( 'rest_pre_dispatch', function ( $result, $server, $request ) {
$route = $request->get_route();
// Only apply to CF7 feedback endpoints.
if ( strpos( $route, '/contact-form-7/' ) === false ) {
return $result;
}
// Verify the request originates from your own domain.
$referer = $request->get_header( 'referer' );
if ( empty( $referer ) || parse_url( $referer, PHP_URL_HOST ) !== parse_url( home_url(), PHP_URL_HOST ) ) {
return new WP_Error(
'rest_forbidden',
'Direct API access is not permitted.',
array( 'status' => 403 )
);
}
// Validate a custom anti-spam token (set by front-end JS).
$token = $request->get_param( '_antispam_token' );
if ( empty( $token ) || ! my_verify_antispam_token( $token ) ) {
return new WP_Error(
'rest_forbidden',
'Invalid submission token.',
array( 'status' => 403 )
);
}
return $result;
}, 10, 3 );
Important caveat: The Referer header can be spoofed. It blocks unsophisticated bots, but it shouldn’t be your only check. The custom token—generated server-side, embedded in the form HTML, and validated on submission—is the real defense layer here.
Layer 4: Implement a Stateless Anti-Spam Token
Here’s a complete implementation of a server-side token that bots can’t forge without loading the page:
/**
* Generate a stateless HMAC token for form validation.
* The token contains the form ID and timestamp, signed with a server secret.
*/
function my_generate_form_token( $form_id ) {
$timestamp = time();
$payload = $form_id . '|' . $timestamp;
$signature = hash_hmac( 'sha256', $payload, wp_salt( 'auth' ) );
return base64_encode( $payload . '|' . $signature );
}
/**
* Verify the token server-side.
* Rejects tokens older than 1 hour or with invalid signatures.
*/
function my_verify_antispam_token( $token, $form_id = null ) {
$decoded = base64_decode( $token, true );
if ( $decoded === false ) {
return false;
}
$parts = explode( '|', $decoded );
if ( count( $parts ) !== 3 ) {
return false;
}
list( $token_form_id, $timestamp, $signature ) = $parts;
// Verify form ID matches (if specified).
if ( $form_id !== null && $token_form_id !== (string) $form_id ) {
return false;
}
// Reject tokens older than 3600 seconds (1 hour).
if ( abs( time() - (int) $timestamp ) > 3600 ) {
return false;
}
// Recompute the signature and compare.
$expected_payload = $token_form_id . '|' . $timestamp;
$expected_signature = hash_hmac( 'sha256', $expected_payload, wp_salt( 'auth' ) );
return hash_equals( $expected_signature, $signature );
}
This token is generated at page load time and embedded in the form. Specifically, it should be output as <input type="hidden" name="_antispam_token" value="..."> in the form HTML and returned as POST data on submission. If a bot sends directly to the API without loading the page, there’s no token. If a bot replays an old token, the timestamp check rejects it. If a bot tries to forge a token, the HMAC check catches it.
Key advantage: This approach is stateless. No database writes, no session storage, no transients bloating wp_options. The server generates the token, the browser sends it back, and the server verifies it using only the secret key it already has.
Layer 5: Rate Limit at the Web Server Level
Application-level rate limiting in PHP is expensive—by the time it runs, WordPress is already loaded. Push rate limiting to Nginx or Apache, and the cost per rejected request is negligible:
# Limit all POST requests to the REST API to 10 per minute per IP.
limit_req_zone $binary_remote_addr zone=restapi:10m rate=10r/m;
location ~* ^/wp-json/ {
# Apply rate limiting only to POST/PUT/PATCH/DELETE.
limit_except GET HEAD OPTIONS {
limit_req zone=restapi burst=5 nodelay;
}
# Pass through to PHP-FPM as normal.
try_files $uri $uri/ /index.php?$args;
}
For Apache, an equivalent configuration using mod_ratelimit or .htaccess rules with mod_rewrite:
# Block direct POST to CF7 endpoint without valid Referer.
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{REQUEST_METHOD} POST
RewriteCond %{REQUEST_URI} ^/wp-json/contact-form-7/
RewriteCond %{HTTP_REFERER} !^https://example.com [NC]
RewriteRule .* - [F,L]
</IfModule>
Again, a Referer check alone isn’t sufficient against sophisticated bots. But it costs zero, runs before PHP starts, and can reduce much of the simplest automated traffic in many cases.
Layer 6: Disable REST Endpoints You Don’t Use
The REST API is a core WordPress feature, and fully disabling it is not recommended. However, if you’re not using a headless front end, you probably don’t need most REST endpoints publicly accessible. Selectively disable the ones you don’t use:
/**
* Remove REST API endpoints this site doesn't need.
* Keep only routes required by core functionality and active plugins.
*/
add_filter( 'rest_endpoints', function ( $endpoints ) {
// Routes to remove on sites not using REST API externally.
$routes_to_remove = array(
'/wp/v2/users',
'/wp/v2/users/(?P<id>[\\d]+)',
'/wp/v2/comments',
'/wp/v2/comments/(?P<id>[\\d]+)',
'/wp/v2/search',
'/wp/v2/block-renderer',
);
foreach ( $routes_to_remove as $route ) {
unset( $endpoints[ $route ] );
}
return $endpoints;
} );
Test before deploying. The block editor (Gutenberg) uses REST endpoints extensively. Disabling routes the editor depends on will break the admin. Always test in a staging environment and verify that post editing, media uploads, and plugin settings work normally.
Putting It All Together: A Complete Security Configuration
Here’s a single mu-plugin file that combines the key protections. Drop it into wp-content/mu-plugins/ and it loads automatically on every request, before regular plugins:
<?php
/**
* Plugin Name: REST API Security Hardening
* Description: Restricts REST API access for unauthenticated users.
* Version: 1.0.0
*/
// Prevent direct access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* 1. Hide the REST API index from anonymous users.
*/
add_filter( 'rest_authentication_errors', function ( $result ) {
if ( is_wp_error( $result ) ) {
return $result;
}
if ( is_user_logged_in() ) {
return $result;
}
$route = $GLOBALS['wp']->query_vars['rest_route'] ?? '';
if ( empty( $route ) || $route === '/' ) {
return new WP_Error( 'rest_no_index', 'Not available.', array( 'status' => 403 ) );
}
return $result;
} );
/**
* 2. Remove sensitive endpoints for anonymous users.
*/
add_filter( 'rest_endpoints', function ( $endpoints ) {
if ( is_user_logged_in() ) {
return $endpoints;
}
$restricted = array(
'/wp/v2/users',
'/wp/v2/users/(?P<id>[\\d]+)',
'/wp/v2/users/me',
);
foreach ( $restricted as $route ) {
unset( $endpoints[ $route ] );
}
return $endpoints;
} );
/**
* 3. Apply Referer validation to Contact Form 7 REST submissions.
*/
add_filter( 'rest_pre_dispatch', function ( $result, $server, $request ) {
if ( $request->get_method() !== 'POST' ) {
return $result;
}
$route = $request->get_route();
if ( strpos( $route, '/contact-form-7/' ) === false ) {
return $result;
}
$referer = $request->get_header( 'referer' );
$home_host = parse_url( home_url(), PHP_URL_HOST );
if ( empty( $referer ) || parse_url( $referer, PHP_URL_HOST ) !== $home_host ) {
return new WP_Error(
'rest_forbidden',
'Direct API submissions are not allowed.',
array( 'status' => 403 )
);
}
return $result;
}, 10, 3 );
This covers the three highest-impact hardening steps in roughly 60 lines of code. For production, add the Layer 4 HMAC token system and Layer 5 web server rate limiting on top.
What This Article Doesn’t Cover, and What Does
The techniques in this article target the REST API attack surface specifically. They prevent bots from bypassing front-end defenses and accessing endpoints directly. But they don’t cover the full spectrum of form spam:
- Bots that load the page (headless browsers running Puppeteer or Playwright) can pass Referer checks and render the form to obtain anti-spam tokens.
- AI agent bots can read form structure, generate contextually appropriate messages, and adapt to failed submissions.
- Distributed botnets rotate through thousands of IP addresses, defeating IP-based rate limits.
REST API hardening is one layer. It blocks the low-effort, high-volume direct POST bots. But it needs to be combined with behavioral analysis, timing verification, and server-side challenge mechanisms that work regardless of how the submission is sent.
For WordPress sites running Contact Form 7, Samurai Honeypot for Forms is designed for exactly this purpose. It uses polymorphic honeypots, proof-of-work challenges, and stateless HMAC tokens to validate submissions at the application layer, catching both the direct API bots covered in this article and the headless browser bots that get past them. Zero configuration, no external dependencies, no user friction.
Key Takeaways
- The WordPress REST API exposes your site’s endpoints to everyone by default. Bots use the
/wp-json/route index as a reconnaissance tool to discover targets. - Front-end defenses alone are insufficient when bots POST directly to the API. Honeypots, JavaScript challenges, and CAPTCHAs only work when the bot loads the page. Many bots don’t.
- WordPress nonces are session-bound, not true nonces. They authenticate logged-in users, but do nothing against anonymous API requests unless the endpoint’s permission callback enforces authentication.
- Stateless HMAC tokens are the strongest lightweight defense for endpoints that must accept anonymous submissions. Cheap to generate, impossible to forge, and they require no database state.
- Layer your defenses. Disable the route index. Remove endpoints you don’t use. Validate Referers. Require server-generated tokens. Rate limit at the web server. No single measure is sufficient on its own, but combined, they raise the cost of automated attacks high enough that bots move on to easier targets.
The REST API is the powerful feature that made WordPress a practical platform for modern development. It’s also an attack surface that most sites leave completely unguarded. Get ahead of the bots.