A custom REST endpoint that writes to your site accepts requests from the open internet, so its security is the whole feature, not a footnote. Hardening one is a sequence of layers, each closing a specific hole: put the secret in a header and compare it in constant time, sign the payload with HMAC so the secret never travels, add a timestamp and nonce to stop replays, rate-limit bad keys, keep the secret out of the repo, and hide the route from discovery. None is hard on its own; the discipline is doing all of them. This builds on the Google-Sheet push endpoint, which started with the bare minimum, a shared secret, on purpose. Here is the path from there to hardened.
Layer 1: secret in a header, compared in constant time
The baseline is a shared secret, but where and how you check it matters. Put it in a header, not the URL (URLs land in access logs, browser history, and referrers) and not the body (cleaner to keep auth out of payload). Compare it with hash_equals, never ===, because a plain string compare leaks timing information a patient attacker can use to guess the secret byte by byte. And check it in permission_callback so an unauthorized request never reaches your handler:
function te_secure_authorize( WP_REST_Request $request ): bool {
if ( ! defined( 'TE_ENDPOINT_SECRET' ) || '' === TE_ENDPOINT_SECRET ) {
return false;
}
$sent = (string) $request->get_header( 'x-te-key' );
return '' !== $sent && hash_equals( TE_ENDPOINT_SECRET, $sent );
}Layer 2: HMAC so the secret never travels
A header secret is still sent on every request; anyone who captures one request has it. The fix is an HMAC signature: the client signs the request body with the secret and sends only the signature. The secret itself never crosses the wire, so capturing a request reveals nothing reusable. The server recomputes the signature over the body and compares:
function te_secure_verify_signature( WP_REST_Request $request ): bool {
$body = $request->get_body();
$sent = (string) $request->get_header( 'x-te-signature' );
$want = hash_hmac( 'sha256', $body, TE_ENDPOINT_SECRET );
return '' !== $sent && hash_equals( $want, $sent );
}Layer 3: timestamp and nonce against replays
An HMAC-signed request is still valid forever, so a captured request can be replayed. Bind each request to a moment and a one-time token. Include a timestamp in the signed payload, reject anything older than a small window (say 5 minutes), and record each nonce so it can't be reused:
function te_secure_check_replay( WP_REST_Request $request ): bool {
$ts = (int) $request->get_header( 'x-te-timestamp' );
$nonce = (string) $request->get_header( 'x-te-nonce' );
if ( abs( time() - $ts ) > 300 ) {
return false; // stale: outside the 5-minute window
}
if ( '' === $nonce || get_transient( 'te_nonce_' . $nonce ) ) {
return false; // missing, or already used
}
set_transient( 'te_nonce_' . $nonce, 1, 600 ); // remember it past the window
return true;
}The timestamp must be inside the HMAC-signed data, or an attacker just rewrites it.
Layer 4: rate-limit the guessers
Even with constant-time compares, an endpoint that answers unlimited bad-key attempts invites brute force and is a denial-of-service amplifier. Count failures per IP in a transient and lock out after a threshold:
function te_secure_rate_ok( string $ip ): bool {
$key = 'te_fail_' . md5( $ip );
$fail = (int) get_transient( $key );
if ( $fail >= 10 ) {
return false; // locked out for the transient's TTL
}
return true;
}
// On an auth failure: set_transient( $key, $fail + 1, 600 );Layer 5: keep the secret out of the repo
The strongest crypto is undone by a secret committed to Git. Define it in wp-config.php (or an environment variable read into a constant), never in the plugin file, and never in version control:
// wp-config.php (not tracked in the repo)
define( 'TE_ENDPOINT_SECRET', getenv( 'TE_ENDPOINT_SECRET' ) ?: '' );Rotate it if it ever leaks, treat it like a password (long, random, from a manager), and make sure your error responses never echo it back.
Layer 6: hide the route and enforce HTTPS
WordPress advertises every registered route at /wp-json. There is no need to announce a private write endpoint. Hide it from the index, and mask a missing/blocked request as a 404 rather than confirming the route exists:
add_filter( 'rest_endpoints', function ( $endpoints ) {
unset( $endpoints['/te-secure/v1/post'] );
return $endpoints; // still callable, just not listed at /wp-json
} );And enforce transport: reject plain HTTP so the request (and any header) is never sent in cleartext, and never follow redirects while carrying the secret. With the layers stacked, the endpoint's behaviour is exactly what you want, the right request succeeds, every wrong one is refused:

The native alternative: Application Passwords
Since WordPress 5.6 (December 2020), core ships Application Passwords, per-user credentials for the REST API over HTTP Basic auth (on HTTPS). If you are hitting core endpoints (/wp/v2/posts), use them, they are revocable per application and need no custom code. For a small custom route doing one thing, a dedicated HMAC secret keeps the surface area tiny and the auth explicit, but Application Passwords are the right call the moment you are leaning on core's own write endpoints.
Sources
Authoritative references this article was fact-checked against.
- register_rest_route() - WordPress Developer Referencedeveloper.wordpress.org
- hash_equals - PHP Manualphp.net
- hash_hmac - PHP Manualphp.net
- Authentication (Application Passwords) - WordPress REST API Handbookdeveloper.wordpress.org
- WP_REST_Request::get_header() - WordPress Developer Referencedeveloper.wordpress.org





