WooCommerce & HPOS4,772 words

Securing WordPress Webhooks with HMAC SHA-256 and Replay Protection

Secure inbound WordPress webhooks with raw-body HMAC verification, constant-time comparison, timestamp windows, event deduplication and queued processing.

ContentsKey takeaways

On this page0% read

moxseo securing wordpress webhooks hmac replay protection featured

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 OK in 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:

  1. Read the current Unix time: $timestamp = time();.
  2. Build the string to sign: $signed_payload = $timestamp . '.' . $raw_request_body;.
  3. Hash it with the shared key: $signature = hash_hmac('sha256', $signed_payload, $secret_key);.
  4. Send both values in one header, for example X-WPStack-Signature: t=1757283920,v1=9f83....

On the WordPress side:

  1. Pull the timestamp (t) and the signature (v1) out of the header.
  2. Make sure abs(time() - $timestamp) <= 300 seconds, which blocks replays.
  3. Read the untouched body from php://input.
  4. Recompute $expected_signature = hash_hmac('sha256', $timestamp . '.' . $raw_body, $secret_key);.
  5. 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):

AspectShared-secret HMAC SHA-256Public-key signatures (RSA / Ed25519)
KeysOne secret held by both the provider and your serverProvider keeps the private key; your server stores only the public key
CPU costVery low (hashing takes microseconds)Higher (modular exponentiation or elliptic curve math)
If your site is breachedA leaked secret lets an attacker sign fake webhooksThe public key cannot be used to forge signatures
Code neededBuilt-in PHP hash_hmac()openssl_verify() plus handling of X.509 certificates
Who uses itStripe, GitHub, Shopify, Slack, Twilio, PaddleApple 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:

  1. 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');
  2. Phase 2, switch the provider: Change the secret in the provider dashboard (Stripe, HubSpot and so on). While you do, HmacSignatureValidator keeps accepting requests signed with either key.
  3. Phase 3, retire the old key: When the logs show that all traffic is signed with new_secret_key_2026, delete WPSTACK_WEBHOOK_SECRET_OLD from 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

SymptomLikely causeWhat to do
HTTP 401: missing_signatureNginx or Apache dropped the custom X-WPStack-Signature headerEnable underscores_in_headers on; in Nginx or check that the proxy forwards the header
HTTP 401: expired_timestampThe server clock is more than 300 seconds off NTP timeResync with timedatectl set-ntp on or chrony
HTTP 403: invalid_signatureA proxy changed the body, or verification ran on parsed JSONAlways verify against the raw php://input stream
Provider logs HTTP 504 timeoutsThe handler ran long synchronous work before replyingQueue the work in Action Scheduler and return HTTP 200 inside 500ms
The same action ran twiceThe provider retried after a connection resetMake sure wpstack_webhook_events has a unique event_id column
HTTP 429: rate_limit_exceededThe provider is sending bursts above the configured thresholdRaise the Redis bucket capacity or bypass the limiter for the provider CIDR range
HTTP 403: mtls_verification_failedThe partner certificate expired or the intermediate CA chain is incompleteCheck 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.

Want this applied to your site?

A free 30-minute call with Sakshi Kumari or Ashish Khan, the specialists who run MoxSEO. Bring your site and your numbers; they will tell you what to fix first and what the data cannot tell you.

A call is not an engagement. SEO plans start at $584 a month, and we never guarantee rankings.

How the call works

  1. Send your siteBook a slot and tell us which WooCommerce & HPOS questions you want answered for your site.
  2. We review it firstBefore the call we look at crawl health, indexation and AI citation coverage on your pages.
  3. Thirty minutes, ranked fixesYou leave knowing what to fix first and why. No proposal unless you ask for one.