TechEarl

Securing a WordPress REST API Write Endpoint

A custom write endpoint accepts changes from the open internet. Harden it step by step: header secret, constant-time compare, HMAC signatures, replay protection, rate limiting, secret out of the repo, and a hidden route.

Ishan Karunaratne⏱️ 6 min readUpdated
Share thisCopied
Hardening a custom WordPress REST API write endpoint against unauthorized and replayed requests

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:

php
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:

php
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:

php
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:

php
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:

php
// 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:

php
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:

A terminal making four requests to the hardened endpoint: no key returns 401, a wrong key returns 401, a valid HMAC-signed request returns 200, and a replayed request with the same nonce returns 401
The hardened endpoint against real requests: no key and wrong key are refused, a valid signed request succeeds, and replaying it (same nonce) is rejected.

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.

TagsWordPressREST APISecurityHMACPHP

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts