Technote

Security Practical

Series The REST API and going headless Part 5 of 8

Receiving webhooks: a nonce check fails every time

An external server has no cookie and no nonce. Verify by signature or shared key, compare with hash_equals, and always sign the raw body.

Webhooks are how a payment, mail or deployment service tells you something happened. They are also the one place where none of the previous part’s rules apply. The caller is not a browser but somebody else’s server, and it holds neither your cookie nor your nonce.

A nonce check fails one hundred per cent of the time

wp_verify_nonce() validates a value tied to the current user and the current time. An external server is not logged in and has no way to mint one, so putting a nonce check on a webhook route makes every request fail, without exception. And it fails quietly: the provider retries a few times, gives up, and all you see on your side is “payments are not being confirmed”.

So a webhook route uses '__return_true' for its permission_callback. That is not an abandonment of verification — it means verification lives somewhere else, in the first lines of the handler, as a signature check.

Both are REST routes, but verification sits in a different place

Sign the raw body, not a re-encoded one

This is the most frequently broken part. People json_decode() the payload and then json_encode() it again to compute the hash. That changes key order, whitespace and unicode escaping, so the signature can never match. The values are identical; the bytes are not.

Use $request->get_body() to get exactly the string that arrived, and parse only after verification succeeds.

add_action( 'rest_api_init', function () {
	register_rest_route(
		'wper/v1',
		'/webhook/payment',
		[
			'methods'             => 'POST',
			'callback'            => 'wper_receive_payment_hook',
			// No cookie exists on the caller. The handler authenticates instead.
			'permission_callback' => '__return_true',
		]
	);
} );

function wper_receive_payment_hook( WP_REST_Request $request ) {
	$raw    = $request->get_body();                 // raw. never re-serialise.
	$sent   = (string) $request->get_header( 'x-signature' );
	$expect = hash_hmac( 'sha256', $raw, wper_webhook_secret() );

	// Never ==. It leaks timing, and PHP has loose comparison surprises.
	if ( ! hash_equals( $expect, $sent ) ) {
		return new WP_Error( 'wper_bad_signature', 'signature mismatch', [ 'status' => 401 ] );
	}

	$event = json_decode( $raw, true );
	$id    = isset( $event['id'] ) ? sanitize_text_field( $event['id'] ) : '';

	if ( '' === $id || get_transient( 'wper_hook_' . $id ) ) {
		return rest_ensure_response( [ 'status' => 'success' ] );   // duplicate: succeed quietly
	}

	set_transient( 'wper_hook_' . $id, 1, DAY_IN_SECONDS );
	wper_apply_payment_event( $event );

	return rest_ensure_response( [ 'status' => 'success' ] );
}

Compare with hash_equals() and nothing else. String comparison with == returns as soon as it finds a difference, which leaks information through timing, and PHP’s loose comparison has its own surprises with certain string shapes. Never compare a secret with == is a rule that is easier to keep absolutely than conditionally.

Retries, idempotency and cache bypass

Webhooks deliver the same event more than once. A dropped connection or a slow 2xx from you triggers a retry. Store the event identifier and skip anything already handled — that is what the transient above is doing. And answer a duplicate with success, not an error: an error keeps the retries coming forever.

There is one more failure that really happens in production. If a page cache caches the webhook route, state stops updating. An nginx FastCGI cache or a CDN stores the first response and stops sending requests to PHP; the provider keeps receiving 200 while your database never changes. Nothing looks wrong in the logs, and only the payments fail to register. This is why webhook and payment routes must be in the cache bypass conditions.

Finally, do not trust amounts or statuses from the payload. Accept only the fact that an event occurred, then re-read the amount from the order record you created and compare.

The security thinking behind checks like these lives in the Security archive, and getting cache layers and payment routes to stop contradicting each other is part of the work in our optimization program.

Next part

The next part points the other way: what the API hands out without being asked — how usernames leak by default through the users endpoint and author archives, and how to close that.

More on this topic

All technotes

Security Practical

Lead data is an asset and a liability at once

A contact list is a marketing asset and personal data at the same time. Collect the minimum, set a retention period, and actually delete when it expires.

Marketers 6 min read

₩270,000 · Join the program