
Key takeaways
- A webhook URL is a public door. Without signature checks, anyone who finds it can post a fake payment confirmation or push forged records into your site.
- Compute the HMAC-SHA256 over the exact bytes you received (
php://input). Decoding or sanitizing the JSON first changes whitespace and key order, and the signatures stop matching. - Compare signatures with
hash_equals(), which takes the same time wherever the strings differ, so response timing cannot leak the secret. - Reject stale timestamps (for example older than 300 seconds) and store every event ID once. Together these two checks stop replayed deliveries.
- Verify the signature, answer
HTTP 200 OKin roughly 500ms and hand the real work to Action Scheduler. A slow handler also holds PHP workers that your visitors need, which shows up as slower pages. - Accept two secrets during a rotation window so you can change keys without losing a single production event.
Many WordPress sites now depend on inbound webhooks to keep business data in step with outside services: payment events from Stripe, subscription changes from Paddle, contact updates from HubSpot, shipping updates from ShipStation. Webhooks are what make those integrations event-driven instead of poll-driven.
The catch is that a receiving endpoint has to be reachable from the public internet, so it can be found and hit with fake requests just as easily as it can be called by the real provider. An endpoint without strong authentication is an open invitation.
Weak schemes are common: a static value in the query string (?secret_token=12345), a WordPress nonce (which only works for logged-in browser sessions, not server-to-server calls), or simply trusting whatever JSON arrives. Any of these lets an attacker send a convincing payment.completed event and walk away with a premium download that was never paid for.
The accepted answer is HMAC SHA-256 (Hash-based Message Authentication Code) verification, backed by timestamp checks against replays, rate limiting and a dead-letter queue for events that fail.
This guide walks through how HMAC signatures work, why timing attacks and payload changes break naive checks, how shared-secret and public-key signing compare, and how to build PSR-4 senders and receivers for WordPress. It then covers secret rotation without downtime, dead-letter recovery, IP allowlisting, mutual TLS, Redis rate limiting and a PHPUnit test suite. The same habits keep your public endpoints from dragging down page speed and crawl performance when traffic spikes.
Five Ways an Exposed Webhook Endpoint Gets Attacked
A receiver has to hold up against five common attack patterns:
1. Forged payloads
Someone posts hand-made JSON to your endpoint, for example marking an order completed or promoting a user to administrator. If you do not verify a signature, nothing tells you whether Stripe or an attacker with cURL sent it.
2. Tampering in transit
TLS protects the connection, but a proxy or a compromised hop on the path can still change fields on the way through, such as a shipping address or the number of licenses a customer is entitled to.
3. Replayed deliveries
An attacker captures a genuine, correctly signed request, such as a balance credit, and sends it again and again. The signature is valid every time, so a receiver that only checks the signature will credit the account on each replay.
4. Timing side channels
Operators like === and strcmp() stop at the first differing byte. Measuring response times across thousands of attempts lets an attacker work out the correct signature one character at a time until they can forge a valid header.
5. Worker exhaustion from slow handlers
Providers give up after a few seconds, usually 5 to 15. If your handler builds PDFs, imports large files or calls other APIs before it replies, every request occupies a PHP worker for that whole time. Enough of them and real visitors queue behind webhook traffic.
How an HMAC Signature Is Built and Checked
An HMAC mixes a secret key with a message through SHA-256 and produces a 256-bit digest, written as hexadecimal. Only the two parties who know the key can create or check it.
On the sending side:
- Read the current Unix time:
$timestamp = time();. - Build the string to sign:
$signed_payload = $timestamp . '.' . $raw_request_body;. - Hash it with the shared key:
$signature = hash_hmac('sha256', $signed_payload, $secret_key);. - Send both values in one header, for example
X-WPStack-Signature: t=1757283920,v1=9f83....
On the WordPress side:
- Pull the timestamp (
t) and the signature (v1) out of the header. - Make sure
abs(time() - $timestamp) <= 300seconds, which blocks replays. - Read the untouched body from
php://input. - Recompute
$expected_signature = hash_hmac('sha256', $timestamp . '.' . $raw_body, $secret_key);. - Compare the two values with
hash_equals($expected_signature, $signature).
Shared-Secret HMAC or Public-Key Signatures?
Stripe, Shopify, GitHub and Paddle all use a shared-secret HMAC-SHA256. A few enterprise providers, such as Apple App Store Server Notifications and DocuSign, sign with a private key instead (RSA-SHA256, or Ed25519 inside JSON Web Signatures):
| Aspect | Shared-secret HMAC SHA-256 | Public-key signatures (RSA / Ed25519) |
|---|---|---|
| Keys | One secret held by both the provider and your server | Provider keeps the private key; your server stores only the public key |
| CPU cost | Very low (hashing takes microseconds) | Higher (modular exponentiation or elliptic curve math) |
| If your site is breached | A leaked secret lets an attacker sign fake webhooks | The public key cannot be used to forge signatures |
| Code needed | Built-in PHP hash_hmac() | openssl_verify() plus handling of X.509 certificates |
| Who uses it | Stripe, GitHub, Shopify, Slack, Twilio, Paddle | Apple StoreKit, DocuSign, legacy PayPal IPN |
Verify Against the Raw Body, Not Parsed JSON
A very common mistake is to check the signature against JSON that has already been parsed and rebuilt, rather than the exact bytes that arrived. Take this payload from a provider:
{"order_id": 4021, "amount": 99.00, "status": "completed"}Run it through json_decode() and wp_json_encode() and PHP may reorder keys, print 99 for 99.00 or change how characters are escaped:
{"amount":99,"order_id":4021,"status":"completed"}A hash changes completely when a single bit differs (the avalanche effect), so the signature of the rebuilt string never matches and genuine events are rejected. Read the raw stream with $request->get_body() or file_get_contents('php://input') and sign that.
Building It: From Outbound Signing to Inbound Verification
Step 1: Sending signed webhooks
When your plugin sends webhooks to other services, route every call through one WebhookDispatcherService. It signs each request in the same way and schedules retries automatically:
<?php
declare(strict_types=1);
namespace WPStack\Webhooks;
use WP_Error;
final class WebhookDispatcherService {
public const SIGNATURE_HEADER = 'X-WPStack-Signature';
/**
* Dispatch a signed webhook payload to an external endpoint.
*
* @param string $destination_url Remote endpoint URL.
* @param array<string, mixed> $payload Event payload.
* @param string $secret_key Shared HMAC signing secret.
* @return bool|WP_Error True on HTTP 2xx response, WP_Error on failure.
*/
public static function dispatch(string $destination_url, array $payload, string $secret_key): bool|WP_Error {
$timestamp = time();
$raw_body = wp_json_encode($payload);
if (false === $raw_body) {
return new WP_Error('json_encode_error', 'Failed to serialize webhook payload.');
}
// Construct cryptographic signature
$signature_payload = $timestamp . '.' . $raw_body;
$signature_hash = hash_hmac('sha256', $signature_payload, $secret_key);
// Format header: t=timestamp,v1=signature
$header_value = sprintf('t=%d,v1=%s', $timestamp, $signature_hash);
$response = wp_remote_post($destination_url, [
'timeout' => 10,
'redirection' => 0,
'httpversion' => '1.1',
'headers' => [
'Content-Type' => 'application/json',
'User-Agent' => 'WPStack-Webhook-Engine/1.0',
self::SIGNATURE_HEADER => $header_value,
],
'body' => $raw_body,
'data_format' => 'body',
]);
if (is_wp_error($response)) {
return $response;
}
$status_code = wp_remote_retrieve_response_code($response);
if ($status_code < 200 || $status_code >= 300) {
return new WP_Error(
'http_delivery_error',
sprintf('Webhook delivery failed with HTTP status code %d', $status_code),
['status' => $status_code, 'body' => wp_remote_retrieve_body($response)]
);
}
return true;
}
}Step 2: The signature validator
The validator reads the signature header, checks the timestamp, takes the raw body, calculates the digest and compares it in constant time. It can also check against two secrets at once for rotation:
<?php
declare(strict_types=1);
namespace WPStack\Webhooks;
use WP_REST_Request;
use WP_Error;
final class HmacSignatureValidator {
public const MAX_TOLERANCE_SECONDS = 300; // 5-minute replay tolerance window
/**
* Verify incoming webhook request against configured secret keys.
*
* @param WP_REST_Request $request
* @param string|array<string> $secrets Single secret or array of active secrets for zero-downtime rotation.
* @return true|WP_Error
*/
public static function verify_request(WP_REST_Request $request, string|array $secrets): true|WP_Error {
$header = $request->get_header('x-wpstack-signature') ?: $request->get_header('x_wpstack_signature');
if (empty($header)) {
return new WP_Error('missing_signature', 'Missing signature header in request.', ['status' => 401]);
}
// Parse header components: t=timestamp,v1=hash
$parsed = self::parse_signature_header($header);
if (!$parsed) {
return new WP_Error('malformed_signature', 'Malformed signature header format.', ['status' => 400]);
}
$timestamp = $parsed['timestamp'];
$signature = $parsed['signature'];
// Enforce Replay Attack Protection Window
$current_time = time();
if (abs($current_time - $timestamp) > self::MAX_TOLERANCE_SECONDS) {
return new WP_Error('expired_timestamp', 'Webhook timestamp outside allowed tolerance window.', ['status' => 401]);
}
// Extract raw request body
$raw_body = $request->get_body();
if (empty($raw_body)) {
return new WP_Error('empty_payload', 'Webhook body cannot be empty.', ['status' => 400]);
}
$signed_payload = $timestamp . '.' . $raw_body;
$secrets_list = is_array($secrets) ? $secrets : [$secrets];
$is_valid = false;
foreach ($secrets_list as $secret) {
if (empty($secret)) {
continue;
}
$expected_signature = hash_hmac('sha256', $signed_payload, $secret);
// Constant-time comparison prevents side-channel timing attacks
if (hash_equals($expected_signature, $signature)) {
$is_valid = true;
break;
}
}
if (!$is_valid) {
return new WP_Error('invalid_signature', 'HMAC signature verification failed.', ['status' => 403]);
}
return true;
}
/**
* Parse header format: t=1757283920,v1=9f83...
*/
private static function parse_signature_header(string $header): ?array {
$items = explode(',', $header);
$data = [];
foreach ($items as $item) {
$parts = explode('=', trim($item), 2);
if (2 === count($parts)) {
$data[$parts[0]] = $parts[1];
}
}
if (!isset($data['t']) || !isset($data['v1'])) {
return null;
}
return [
'timestamp' => (int)$data['t'],
'signature' => (string)$data['v1'],
];
}
}Step 3: A REST receiver that hands work to a queue
The REST controller registers the public route and runs validation in its permission_callback. It then writes the event to an idempotency table and queues an Action Scheduler job, so the reply is an immediate 200 OK:
<?php
declare(strict_types=1);
namespace WPStack\Webhooks;
use WP_REST_Server;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
final class WebhookReceiverController {
public const HOOK_PROCESS_WEBHOOK = 'wpstack_process_incoming_webhook_event';
public static function init(): void {
add_action('rest_api_init', [self::class, 'register_routes']);
add_action(self::HOOK_PROCESS_WEBHOOK, [self::class, 'execute_background_webhook'], 10, 2);
}
public static function register_routes(): void {
register_rest_route('wpstack/v1', '/webhook-receiver', [
'methods' => WP_REST_Server::CREATABLE,
'callback' => [self::class, 'handle_incoming_webhook'],
'permission_callback' => [self::class, 'validate_webhook_permissions'],
]);
}
/**
* Validate HMAC signature in permission_callback before executing handler.
*/
public static function validate_webhook_permissions(WP_REST_Request $request): bool|WP_Error {
// Enforce rate limiting prior to cryptographic calculation
$rate_check = WebhookRateLimiter::check_rate_limit($request);
if (is_wp_error($rate_check)) {
return $rate_check;
}
// Retrieve active webhook secrets from secure configuration
$primary_secret = defined('WPSTACK_WEBHOOK_SECRET') ? WPSTACK_WEBHOOK_SECRET : '';
$secondary_secret = defined('WPSTACK_WEBHOOK_SECRET_OLD') ? WPSTACK_WEBHOOK_SECRET_OLD : '';
$active_secrets = array_filter([$primary_secret, $secondary_secret]);
if (empty($active_secrets)) {
return new WP_Error('unconfigured_secret', 'Webhook signing secret is not configured on server.', ['status' => 500]);
}
return HmacSignatureValidator::verify_request($request, $active_secrets);
}
/**
* Handle webhook delivery: persist audit log and enqueue background processing.
*/
public static function handle_incoming_webhook(WP_REST_Request $request): WP_REST_Response|WP_Error {
$raw_body = $request->get_body();
$payload = json_decode($raw_body, true);
if (!is_array($payload) || !isset($payload['event_id'])) {
return new WP_Error('invalid_payload', 'Payload missing required event_id identifier.', ['status' => 422]);
}
$event_id = sanitize_text_field((string)$payload['event_id']);
$event_type = sanitize_text_field((string)($payload['event_type'] ?? 'unknown'));
global $wpdb;
$table = $wpdb->prefix . 'wpstack_webhook_events';
// Check for duplicate delivery (Idempotency)
$existing = $wpdb->get_var($wpdb->prepare(
"SELECT id FROM {$table} WHERE event_id = %s",
$event_id
));
if ($existing) {
// Already processed: return 200 OK without re-enqueuing
return new WP_REST_Response([
'status' => 'duplicate_acknowledged',
'message' => 'Event was previously received and acknowledged.',
], 200);
}
// Insert event into ledger
$wpdb->insert($table, [
'event_id' => $event_id,
'event_type' => $event_type,
'payload_json' => $raw_body,
'status' => 'pending',
'received_at' => current_time('mysql', true),
]);
$log_id = $wpdb->insert_id;
// Offload execution to Action Scheduler background queue
if (function_exists('as_enqueue_async_action')) {
as_enqueue_async_action(
self::HOOK_PROCESS_WEBHOOK,
['log_id' => $log_id, 'event_id' => $event_id],
'wpstack-webhooks'
);
}
// Return immediate fast response to provider
return new WP_REST_Response([
'status' => 'success',
'event_id' => $event_id,
'message' => 'Webhook received and enqueued for processing.',
], 200);
}
/**
* Background Action Scheduler callback.
*/
public static function execute_background_webhook(int $log_id, string $event_id): void {
global $wpdb;
$table = $wpdb->prefix . 'wpstack_webhook_events';
$event = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$table} WHERE id = %d",
$log_id
));
if (!$event) {
return;
}
$payload = json_decode($event->payload_json, true);
try {
// Execute business logic based on event_type
do_action('wpstack_webhook_event_' . $event->event_type, $payload);
// Update status to completed
$wpdb->update($table, [
'status' => 'completed',
'processed_at' => current_time('mysql', true),
], ['id' => $log_id]);
} catch (Throwable $e) {
$wpdb->update($table, [
'status' => 'failed',
'error_message' => substr($e->getMessage(), 0, 1000),
'processed_at' => current_time('mysql', true),
], ['id' => $log_id]);
throw new RuntimeException(sprintf('Failed to process webhook event %s: %s', $event_id, $e->getMessage()));
}
}
}Step 4: Verifying public-key signatures with OpenSSL
Some identity and payment providers (Apple StoreKit and DocuSign, for instance) sign events with a private RSA or ECDSA key. Check those signatures against the provider’s published public PEM certificate:
<?php
declare(strict_types=1);
namespace WPStack\Webhooks;
use WP_REST_Request;
use WP_Error;
final class AsymmetricSignatureValidator {
/**
* Verify RSA-SHA256 signature using OpenSSL and public PEM certificate.
*/
public static function verify_rsa_signature(WP_REST_Request $request, string $public_key_pem): true|WP_Error {
$signature_base64 = $request->get_header('x-provider-signature');
if (empty($signature_base64)) {
return new WP_Error('missing_signature', 'Missing asymmetric signature header.', ['status' => 401]);
}
$signature = base64_decode($signature_base64, true);
if (false === $signature) {
return new WP_Error('malformed_signature', 'Signature is not valid base64.', ['status' => 400]);
}
$raw_body = $request->get_body();
$public_key = openssl_pkey_get_public($public_key_pem);
if (false === $public_key) {
return new WP_Error('invalid_public_key', 'Invalid public key certificate format.', ['status' => 500]);
}
// Verify SHA-256 signature against raw body
$verify_result = openssl_verify($raw_body, $signature, $public_key, OPENSSL_ALGO_SHA256);
if (1 === $verify_result) {
return true;
}
return new WP_Error('signature_mismatch', 'Asymmetric cryptographic verification failed.', ['status' => 403]);
}
}Step 5: Rate limits and IP allowlists
A public route can be flooded to use up database connections. Allow only the known address ranges of providers such as Stripe and GitHub where you can, and put a Redis-backed rate limiter in front of everything else:
<?php
declare(strict_types=1);
namespace WPStack\Webhooks;
use WP_REST_Request;
use WP_Error;
final class WebhookRateLimiter {
public const MAX_REQUESTS_PER_MINUTE = 120;
/**
* Check if client IP has exceeded webhook request limits.
*/
public static function check_rate_limit(WP_REST_Request $request): true|WP_Error {
$client_ip = self::get_client_ip();
$cache_key = 'wpstack_webhook_rate_' . md5($client_ip);
$current_hits = (int)wp_cache_get($cache_key, 'rate_limits');
if ($current_hits >= self::MAX_REQUESTS_PER_MINUTE) {
return new WP_Error('rate_limit_exceeded', 'Too many webhook requests from this IP.', [
'status' => 429,
'headers' => ['Retry-After' => 60],
]);
}
wp_cache_set($cache_key, $current_hits + 1, 'rate_limits', 60);
return true;
}
/**
* Optional CIDR IP check for dedicated providers.
*/
public static function is_ip_in_range(string $ip, string $range): bool {
if (!str_contains($range, '/')) {
return $ip === $range;
}
[$subnet, $bits] = explode('/', $range, 2);
$ip_long = ip2long($ip);
$subnet_long = ip2long($subnet);
$mask = -1 << (32 - (int)$bits);
$subnet_long &= $mask;
return ($ip_long & $mask) === $subnet_long;
}
private static function get_client_ip(): string {
if (!empty($_SERVER['HTTP_CF_CONNECTING_IP'])) {
return (string)$_SERVER['HTTP_CF_CONNECTING_IP']; // Cloudflare
}
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$ips = explode(',', (string)$_SERVER['HTTP_X_FORWARDED_FOR']);
return trim($ips[0]);
}
return (string)($_SERVER['REMOTE_ADDR'] ?? '127.0.0.1');
}
}Step 6: A dead-letter queue and a WP-CLI replay command
Some failures cannot be fixed by retrying, for example an ERP endpoint that no longer exists or a payload that no longer fits the schema. Park those events in a dead-letter table where an administrator can inspect and replay them:
<?php
declare(strict_types=1);
namespace WPStack\Webhooks\CLI;
use WP_CLI;
use WP_CLI_Command;
final class WebhookDlqCommand extends WP_CLI_Command {
/**
* Replay failed webhook events from Dead Letter Queue.
*
* ## OPTIONS
*
* [--event-id=<event-id>]
* : Replay specific event ID.
*
* [--all]
* : Replay all failed events.
*
* ## EXAMPLES
*
* wp wpstack webhook replay --event-id=evt_9021
* wp wpstack webhook replay --all
*/
public function replay(array $args, array $assoc_args): void {
global $wpdb;
$table = $wpdb->prefix . 'wpstack_webhook_events';
if (!empty($assoc_args['event-id'])) {
$event_id = sanitize_text_field($assoc_args['event-id']);
$events = $wpdb->get_results($wpdb->prepare("SELECT * FROM {$table} WHERE event_id = %s AND status = 'failed'", $event_id));
} elseif (!empty($assoc_args['all'])) {
$events = $wpdb->get_results("SELECT * FROM {$table} WHERE status = 'failed' ORDER BY id ASC LIMIT 500");
} else {
WP_CLI::error('Please specify --event-id=<id> or --all');
return;
}
if (empty($events)) {
WP_CLI::success('No failed webhook events found matching criteria.');
return;
}
WP_CLI::log(sprintf('Found %d failed events to replay...', count($events)));
foreach ($events as $event) {
$wpdb->update($table, ['status' => 'pending', 'error_message' => null], ['id' => $event->id]);
if (function_exists('as_enqueue_async_action')) {
as_enqueue_async_action(
WPStack\Webhooks\WebhookReceiverController::HOOK_PROCESS_WEBHOOK,
['log_id' => $event->id, 'event_id' => $event->event_id],
'wpstack-webhooks'
);
}
WP_CLI::log("Re-enqueued event: {$event->event_id}");
}
WP_CLI::success('Replay batch successfully dispatched to queue runner!');
}
}
if (defined('WP_CLI') && WP_CLI) {
WP_CLI::add_command('wpstack webhook', WebhookDlqCommand::class);
}Rotating Secrets Without Dropping Events
SOC 2 and PCI-DSS expect API secrets to be rotated on a schedule. If you simply swap the key, every webhook signed with the old one fails until the provider is updated.
A staged rotation avoids that gap:
- Phase 1, accept both: Put the current secret and the new one in
wp-config.php:define('WPSTACK_WEBHOOK_SECRET', 'new_secret_key_2026'); define('WPSTACK_WEBHOOK_SECRET_OLD', 'legacy_secret_key_2025'); - Phase 2, switch the provider: Change the secret in the provider dashboard (Stripe, HubSpot and so on). While you do,
HmacSignatureValidatorkeeps accepting requests signed with either key. - Phase 3, retire the old key: When the logs show that all traffic is signed with
new_secret_key_2026, deleteWPSTACK_WEBHOOK_SECRET_OLDfrom the server configuration.
Optional Extra Layer: Mutual TLS at the Proxy
In banking, healthcare and ERP integrations, application-layer HMAC alone leaves the network edge exposed to volumetric floods. With Mutual TLS (mTLS) at the reverse proxy (Nginx or Cloudflare Enterprise), the server checks the sender’s X.509 certificate during the TLS handshake, so unauthenticated traffic never reaches PHP-FPM.
With mTLS on, the provider presents a client certificate issued by a private certificate authority you trust. Nginx validates it against that CA bundle, extracts the client identity and forwards it to WordPress as fastcgi parameters:
# Nginx /etc/nginx/conf.d/webhook-mtls.conf
server {
listen 443 ssl http2;
server_name api.wpstack.online;
ssl_certificate /etc/ssl/certs/wpstack_server.crt;
ssl_certificate_key /etc/ssl/private/wpstack_server.key;
# Trusted CA for incoming client webhook certificates
ssl_client_certificate /etc/ssl/certs/enterprise_partner_ca.crt;
ssl_verify_client optional; # Enforce strict validation on webhook routes
ssl_verify_depth 2;
location /wp-json/wpstack/v1/webhook-receiver {
if ($ssl_client_verify != "SUCCESS") {
return 403 "Forbidden: Invalid or Missing Client Certificate";
}
fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param SSL_CLIENT_VERIFY $ssl_client_verify;
fastcgi_param SSL_CLIENT_S_DN $ssl_client_s_dn;
include fastcgi_params;
}
}The REST controller then reads those forwarded values and only lets approved partner certificates trigger sensitive database operations:
<?php
declare(strict_types=1);
namespace WPStack\Webhooks;
use WP_Error;
use WP_REST_Request;
final class MtlsCertificateValidator {
/**
* Validate client certificate parameters forwarded from Nginx.
*
* @param WP_REST_Request $request
* @param string $expected_common_name Expected partner CN in Subject DN.
* @return bool|WP_Error
*/
public static function validate_transport_layer(WP_REST_Request $request, string $expected_common_name): bool|WP_Error {
$client_verify = sanitize_text_field($_SERVER['SSL_CLIENT_VERIFY'] ?? '');
$subject_dn = sanitize_text_field($_SERVER['SSL_CLIENT_S_DN'] ?? '');
if ($client_verify !== 'SUCCESS') {
return new WP_Error(
'mtls_verification_failed',
__('Transport layer client certificate verification failed.', 'wpstack'),
['status' => 403]
);
}
if (!str_contains($subject_dn, "CN={$expected_common_name}")) {
return new WP_Error(
'mtls_unauthorized_subject',
__('Client certificate Subject DN does not match expected authority.', 'wpstack'),
['status' => 403]
);
}
return true;
}
}
Burst Control: A Redis and Lua Token Bucket
A flash sale or product launch can send 50,000 webhooks a minute, enough to fill the PHP-FPM worker pool and saturate MySQL connections. Counting requests in MySQL rows makes that worse, so use an atomic token bucket in Redis, run as an inline Lua script.
A token bucket lets bursts through up to a set capacity while refilling at a steady rate. Because the whole calculation and state update happen in one atomic Lua call, nodes in a WordPress cluster cannot race each other:
<?php
declare(strict_types=1);
namespace WPStack\Webhooks;
use Redis;
use WP_Error;
final class DistributedTokenBucketLimiter {
private Redis $redis;
private const LUA_TOKEN_BUCKET_SCRIPT = <<<'LUA'
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local cost = tonumber(ARGV[3])
local now = tonumber(ARGV[4])
local data = redis.call('HMGET', key, 'tokens', 'last_refreshed')
local tokens = tonumber(data[1])
local last_refreshed = tonumber(data[2])
if not tokens then
tokens = capacity
last_refreshed = now
else
local delta = math.max(0, now - last_refreshed)
local tokens_to_add = delta * refill_rate
tokens = math.min(capacity, tokens + tokens_to_add)
last_refreshed = now
end
if tokens >= cost then
tokens = tokens - cost
redis.call('HMSET', key, 'tokens', tokens, 'last_refreshed', last_refreshed)
redis.call('EXPIRE', key, 3600)
return {1, tokens} -- Allowed: 1, Remaining tokens
else
redis.call('HMSET', key, 'tokens', tokens, 'last_refreshed', last_refreshed)
redis.call('EXPIRE', key, 3600)
return {0, tokens} -- Denied: 0, Available tokens
end
LUA;
public function __construct(Redis $redis) {
$this->redis = $redis;
}
/**
* Attempt to consume tokens for an inbound webhook source.
*
* @param string $source_identifier IP address or Client ID.
* @param int $capacity Maximum bucket burst capacity.
* @param float $refill_rate Tokens added per second.
* @param int $cost Tokens required for this request.
* @return bool|WP_Error True if allowed, WP_Error if rate limit exceeded.
*/
public function consume(
string $source_identifier,
int $capacity = 100,
float $refill_rate = 10.0,
int $cost = 1
): bool|WP_Error {
$key = "wpstack:ratelimit:webhook:{$source_identifier}";
$now = microtime(true);
/** @var array{0: int, 1: float} $result */
$result = $this->redis->eval(
self::LUA_TOKEN_BUCKET_SCRIPT,
[$key, $capacity, $refill_rate, $cost, $now],
1
);
if ((int) $result[0] === 1) {
return true;
}
return new WP_Error(
'webhook_rate_limit_exceeded',
sprintf(__('Webhook rate limit exceeded. Bucket capacity: %d. Available tokens: %.2f', 'wpstack'), $capacity, $result[1]),
['status' => 429]
);
}
}
Troubleshooting Matrix for Webhook Incidents
| Symptom | Likely cause | What to do |
|---|---|---|
HTTP 401: missing_signature | Nginx or Apache dropped the custom X-WPStack-Signature header | Enable underscores_in_headers on; in Nginx or check that the proxy forwards the header |
HTTP 401: expired_timestamp | The server clock is more than 300 seconds off NTP time | Resync with timedatectl set-ntp on or chrony |
HTTP 403: invalid_signature | A proxy changed the body, or verification ran on parsed JSON | Always verify against the raw php://input stream |
| Provider logs HTTP 504 timeouts | The handler ran long synchronous work before replying | Queue the work in Action Scheduler and return HTTP 200 inside 500ms |
| The same action ran twice | The provider retried after a connection reset | Make sure wpstack_webhook_events has a unique event_id column |
HTTP 429: rate_limit_exceeded | The provider is sending bursts above the configured threshold | Raise the Redis bucket capacity or bypass the limiter for the provider CIDR range |
HTTP 403: mtls_verification_failed | The partner certificate expired or the intermediate CA chain is incomplete | Check the certificate: openssl x509 -in client.crt -text -noout |
Testing the Security Layer with PHPUnit
Automated tests prove the receiver rejects forged, replayed, expired and altered payloads, including the awkward edge cases:
namespace WPStack\Tests;
use WP_UnitTestCase;
use WP_REST_Request;
use WPStack\Webhooks\HmacSignatureValidator;
class WebhookSecurityTest extends WP_UnitTestCase {
private string $secret = 'test_secret_key_super_secure_123';
public function test_valid_signature_is_accepted(): void {
$payload = ['event_id' => 'evt_101', 'action' => 'user_upgraded'];
$raw_body = json_encode($payload);
$timestamp = time();
$signature = hash_hmac('sha256', $timestamp . '.' . $raw_body, $this->secret);
$request = new WP_REST_Request('POST', '/wpstack/v1/webhook-receiver');
$request->set_header('x-wpstack-signature', "t={$timestamp},v1={$signature}");
$request->set_body($raw_body);
$result = HmacSignatureValidator::verify_request($request, $this->secret);
$this->assertTrue($result);
}
public function test_tampered_payload_is_rejected(): void {
$payload = ['event_id' => 'evt_101', 'action' => 'user_upgraded'];
$raw_body = json_encode($payload);
$timestamp = time();
$signature = hash_hmac('sha256', $timestamp . '.' . $raw_body, $this->secret);
// Tamper with body after signing
$tampered_body = json_encode(['event_id' => 'evt_101', 'action' => 'user_granted_admin']);
$request = new WP_REST_Request('POST', '/wpstack/v1/webhook-receiver');
$request->set_header('x-wpstack-signature', "t={$timestamp},v1={$signature}");
$request->set_body($tampered_body);
$result = HmacSignatureValidator::verify_request($request, $this->secret);
$this->assertWPError($result);
$this->assertEquals('invalid_signature', $result->get_error_code());
}
public function test_expired_timestamp_is_rejected(): void {
$raw_body = json_encode(['event_id' => 'evt_102']);
// 10 minutes in past (outside 300s window)
$expired_timestamp = time() - 600;
$signature = hash_hmac('sha256', $expired_timestamp . '.' . $raw_body, $this->secret);
$request = new WP_REST_Request('POST', '/wpstack/v1/webhook-receiver');
$request->set_header('x-wpstack-signature', "t={$expired_timestamp},v1={$signature}");
$request->set_body($raw_body);
$result = HmacSignatureValidator::verify_request($request, $this->secret);
$this->assertWPError($result);
$this->assertEquals('expired_timestamp', $result->get_error_code());
}
}Putting It Together
Reliable webhook handling comes down to a few habits: sign and verify the raw body, compare in constant time, expire timestamps, deduplicate events and move slow work off the request. MoxSEO’s web development team builds and audits custom WordPress and WooCommerce plugins, REST API integrations and webhook handlers.
If you need help with webhook security, REST API hardening or a review of an existing plugin, see our custom WordPress plugin development services.
Frequently asked questions
What does HMAC SHA-256 do for a webhook?
It mixes a secret key with the request body to produce a code that only the real sender could have created. Matching it proves who sent the message and that the body was not changed on the way.
Why use hash_equals() and not === when checking signatures?
=== stops at the first mismatching character, so response times reveal how much of a guess was right. hash_equals() takes the same time however the strings differ, which closes that side channel.
Why do signatures fail after json_decode()?
Decoding and re-encoding can change whitespace, key order and number formatting. The HMAC must be computed over the exact bytes read from php://input, before any parsing.
How can I stop replayed webhooks?
Sign a timestamp together with the body, refuse anything older than 300 seconds and keep each event_id in an indexed table so repeat deliveries are dropped.
How quickly should a webhook endpoint reply?
Aim for HTTP 200 OK in 200ms to 500ms. Move anything slow, such as migrations, emails or calls to other APIs, into Action Scheduler jobs so PHP workers stay free for visitors.
Are WordPress nonces suitable for webhook authentication?
No. Nonces protect logged-in browser sessions against CSRF and depend on user cookies. An external server has neither, so it cannot use them.
How do I rotate a webhook secret with no downtime?
Let the validator accept both the current and the new secret, change the secret at the provider, watch the logs until only the new one is used, then remove the old one.
Why does Nginx drop the X-WPStack-Signature header?
Nginx ignores request headers that contain underscores unless underscores_in_headers on; is set in the http or server block.






