Webhooks Are Open Doors — Secure Them

Every JekCMS site that uses the content automation API (n8n workflows, custom integrations, external CMS bridges) exposes at least one webhook endpoint. That endpoint accepts POST requests from the internet, processes the payload, and writes data to your database. If you do not verify that the request actually came from your authorized sender, anyone who discovers the URL can inject content into your site.

I learned this the hard way in January 2026 when one of our client sites started publishing spam posts at 3 AM. The n8n webhook URL had leaked through a misconfigured logging service, and someone was sending crafted payloads directly to the endpoint. There was no signature verification — the endpoint accepted any well-formed JSON body. It took 20 minutes to clean up 47 spam posts and implement the signing mechanism I should have built from day one.

This tutorial walks through the complete webhook security implementation we now use across all JekCMS sites. Every technique here is in production, handling about 2,000 webhook calls per day across our network of sites.

How HMAC-SHA256 Signing Works

HMAC (Hash-based Message Authentication Code) creates a unique signature for each request using a shared secret key. The sender computes a hash of the request body using the secret, attaches it as a header, and the receiver recomputes the same hash to verify the request was not tampered with and came from someone who knows the secret.

The algorithm in plain English:

  1. Sender serializes the payload to JSON
  2. Sender computes HMAC-SHA256(json_body, secret_key)
  3. Sender sends the request with the hash in a header: X-Webhook-Signature: sha256=abc123...
  4. Receiver reads the raw request body
  5. Receiver computes the same HMAC using its copy of the secret
  6. Receiver compares the two hashes — if they match, the request is authentic

The secret key never travels over the network. Even if someone intercepts the request, they cannot forge a valid signature without knowing the key.

Sender Side: Signing Requests in PHP

Here is how JekCMS signs outgoing webhook requests (used when JekCMS itself triggers webhooks to external services):

<?php
class WebhookSender {
    private string $secret;
    private string $endpointUrl;

    public function __construct(string $endpointUrl, string $secret) {
        $this->endpointUrl = $endpointUrl;
        $this->secret = $secret;
    }

    public function send(array $payload): array {
        $jsonBody = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        $timestamp = time();
        $signature = $this->computeSignature($jsonBody, $timestamp);

        $headers = [
            'Content-Type: application/json; charset=utf-8',
            'X-Webhook-Signature: sha256=' . $signature,
            'X-Webhook-Timestamp: ' . $timestamp,
            'X-Webhook-Id: ' . bin2hex(random_bytes(16)),
        ];

        $ch = curl_init($this->endpointUrl);
        curl_setopt_array($ch, [
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => $jsonBody,
            CURLOPT_HTTPHEADER => $headers,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT => 30,
            CURLOPT_CONNECTTIMEOUT => 10,
        ]);

        $response = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $error = curl_error($ch);
        curl_close($ch);

        return [
            'success' => $httpCode >= 200 && $httpCode < 300,
            'http_code' => $httpCode,
            'response' => $response,
            'error' => $error ?: null,
        ];
    }

    private function computeSignature(string $body, int $timestamp): string {
        // Include timestamp in the signed content to prevent replay attacks
        $signedContent = $timestamp . '.' . $body;
        return hash_hmac('sha256', $signedContent, $this->secret);
    }
}

Two design decisions worth noting:

  • Timestamp in the signature: We concatenate the timestamp with the body before hashing. This means the same payload sent at two different times produces two different signatures, which is critical for replay attack prevention (covered below).
  • Webhook ID: Each request gets a unique ID via random_bytes(16). This allows the receiver to detect and reject duplicate deliveries if your retry logic sends the same payload twice.

Receiver Side: Verifying Incoming Webhooks

This is the more important half. Here is the complete verification middleware from JekCMS's API handler:

<?php
class WebhookVerifier {
    private string $secret;
    private int $maxAge;      // Maximum allowed age of a request in seconds
    private string $logPath;

    public function __construct(string $secret, int $maxAge = 300) {
        $this->secret = $secret;
        $this->maxAge = $maxAge;
        $this->logPath = ROOT_PATH . '/logs/webhook-security.log';
    }

    public function verify(): bool {
        // Step 1: Read raw body (must happen before any php://input consumption)
        $rawBody = file_get_contents('php://input');
        if (empty($rawBody)) {
            $this->reject('Empty request body', 400);
            return false;
        }

        // Step 2: Extract signature header
        $signatureHeader = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
        if (empty($signatureHeader) || strpos($signatureHeader, 'sha256=') !== 0) {
            $this->reject('Missing or malformed signature header', 401);
            return false;
        }
        $receivedSignature = substr($signatureHeader, 7); // Remove "sha256=" prefix

        // Step 3: Extract and validate timestamp
        $timestamp = (int)($_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? 0);
        if ($timestamp === 0) {
            $this->reject('Missing timestamp header', 401);
            return false;
        }

        $age = abs(time() - $timestamp);
        if ($age > $this->maxAge) {
            $this->reject("Request too old: {$age}s (max {$this->maxAge}s)", 401);
            return false;
        }

        // Step 4: Compute expected signature
        $signedContent = $timestamp . '.' . $rawBody;
        $expectedSignature = hash_hmac('sha256', $signedContent, $this->secret);

        // Step 5: Timing-safe comparison
        if (!hash_equals($expectedSignature, $receivedSignature)) {
            $this->reject('Signature mismatch', 401);
            return false;
        }

        return true;
    }

    private function reject(string $reason, int $httpCode): void {
        $this->log('REJECTED', $reason);
        http_response_code($httpCode);
        echo json_encode(['error' => 'Webhook verification failed']);
        exit;
    }

    private function log(string $level, string $message): void {
        $entry = sprintf(
            "[%s] %s | IP: %s | UA: %s | %s
",
            date('Y-m-d H:i:s'),
            $level,
            $_SERVER['REMOTE_ADDR'] ?? 'unknown',
            substr($_SERVER['HTTP_USER_AGENT'] ?? 'unknown', 0, 100),
            $message
        );
        file_put_contents($this->logPath, $entry, FILE_APPEND | LOCK_EX);
    }
}

Why hash_equals Matters

Line 45 uses hash_equals() instead of === for the comparison. This is not a style preference — it is a security requirement. Standard string comparison (===) exits early on the first mismatched character, which means it takes slightly longer to reject signatures that match more characters. An attacker can measure these timing differences to gradually reconstruct a valid signature, one character at a time. This is called a timing attack.

hash_equals() always takes the same amount of time regardless of how many characters match, eliminating this side channel completely.

Replay Attack Prevention

Even with a valid HMAC signature, a request can be dangerous if it is a replay of a legitimate request captured by an attacker. The timestamp mechanism handles most of this — we reject any request older than 5 minutes (300 seconds). But for additional protection, we track webhook IDs to prevent the exact same request from being processed twice:

class ReplayProtection {
    private string $storePath;
    private int $window;

    public function __construct(int $windowSeconds = 300) {
        $this->storePath = ROOT_PATH . '/cache/webhook_ids/';
        $this->window = $windowSeconds;

        if (!is_dir($this->storePath)) {
            mkdir($this->storePath, 0755, true);
        }
    }

    public function isDuplicate(string $webhookId): bool {
        $file = $this->storePath . md5($webhookId) . '.lock';

        if (file_exists($file)) {
            return true; // Already processed
        }

        // Record this ID
        file_put_contents($file, time(), LOCK_EX);

        // Garbage collect old entries (1% chance per request)
        if (mt_rand(1, 100) === 1) {
            $this->cleanup();
        }

        return false;
    }

    private function cleanup(): void {
        $cutoff = time() - $this->window;
        foreach (glob($this->storePath . '*.lock') as $file) {
            if (filemtime($file) < $cutoff) {
                unlink($file);
            }
        }
    }
}

We use file-based storage for webhook ID tracking because it requires zero additional infrastructure. Each webhook ID creates a small file (just a timestamp). Files older than the replay window are cleaned up probabilistically. For sites processing more than 10,000 webhooks per day, switching to Redis or a database table would be more efficient, but file-based storage handles our current volume without issues.

IP Whitelisting

Signature verification tells you the request is authentic. IP whitelisting tells you the request came from an expected network. We use both layers together for defense in depth.

For n8n webhook integrations, the IP range is known and stable. For external services, most publish their webhook IP ranges in their documentation.

class IpWhitelist {
    private array $allowedRanges;

    public function __construct(array $allowedRanges) {
        $this->allowedRanges = $allowedRanges;
    }

    public function isAllowed(string $ip): bool {
        foreach ($this->allowedRanges as $range) {
            if ($this->ipInRange($ip, $range)) {
                return true;
            }
        }
        return false;
    }

    private function ipInRange(string $ip, string $range): bool {
        if (strpos($range, '/') === false) {
            return $ip === $range; // Exact match
        }

        list($subnet, $bits) = explode('/', $range);
        $ip = ip2long($ip);
        $subnet = ip2long($subnet);
        $mask = -1 << (32 - (int)$bits);

        return ($ip & $mask) === ($subnet & $mask);
    }
}

// Usage in webhook handler
$whitelist = new IpWhitelist([
    '10.0.0.0/8',        // Internal network
    '203.0.113.50',      // n8n server
    '198.51.100.0/24',   // External service range
]);

if (!$whitelist->isAllowed($_SERVER['REMOTE_ADDR'])) {
    http_response_code(403);
    exit('IP not allowed');
}

One warning: if your server sits behind a reverse proxy or CDN (Cloudflare, for example), $_SERVER['REMOTE_ADDR'] will be the proxy IP, not the original client IP. In that case, you need to read the X-Forwarded-For or CF-Connecting-IP header. But be careful — those headers can be spoofed if the request bypasses the proxy. Only trust forwarded headers if you have verified that all traffic comes through the proxy.

Payload Validation

A signed, timestamped, IP-verified request can still contain malicious data. The payload itself needs validation before it touches your database. Here is our validation layer for the content publishing webhook:

class PayloadValidator {
    private array $errors = [];

    public function validatePublishPayload(array $data): bool {
        $this->errors = [];

        // Required fields
        $this->requireString($data, 'title', 3, 500);
        $this->requireString($data, 'content', 50, 500000);
        $this->requireInt($data, 'author_id', 1, 10000);

        // Optional but validated if present
        if (!empty($data['featured_image'])) {
            $this->validateUrl($data['featured_image'], 'featured_image');
        }

        if (!empty($data['category_id'])) {
            $this->requireInt($data, 'category_id', 1, 10000);
        }

        // Reject unexpected fields (prevents mass assignment)
        $allowed = ['title', 'content', 'author_id', 'featured_image',
                    'image_alt_text', 'image_description', 'category_id',
                    'task_id', 'excerpt'];
        $unexpected = array_diff(array_keys($data), $allowed);
        if (!empty($unexpected)) {
            $this->errors[] = 'Unexpected fields: ' . implode(', ', $unexpected);
        }

        return empty($this->errors);
    }

    private function requireString(array $data, string $field, int $min, int $max): void {
        if (!isset($data[$field]) || !is_string($data[$field])) {
            $this->errors[] = "{$field} is required and must be a string";
            return;
        }
        $len = mb_strlen($data[$field]);
        if ($len < $min || $len > $max) {
            $this->errors[] = "{$field} must be between {$min} and {$max} characters";
        }
    }

    private function requireInt(array $data, string $field, int $min, int $max): void {
        $value = $data[$field] ?? null;
        if (!is_numeric($value)) {
            $this->errors[] = "{$field} is required and must be numeric";
            return;
        }
        if ((int)$value < $min || (int)$value > $max) {
            $this->errors[] = "{$field} must be between {$min} and {$max}";
        }
    }

    private function validateUrl(string $url, string $field): void {
        if (!filter_var($url, FILTER_VALIDATE_URL)) {
            $this->errors[] = "{$field} is not a valid URL";
        }
    }

    public function getErrors(): array {
        return $this->errors;
    }
}

The whitelist approach on line 22 is essential. Instead of trying to block dangerous fields, we define exactly which fields are allowed and reject anything else. This prevents an attacker from sending fields like role or is_admin that might be processed by a naive mass-assignment pattern.

Putting It All Together

Here is how the complete verification chain looks in a JekCMS webhook endpoint:

<?php
require_once __DIR__ . '/../config/config.php';

// Layer 1: IP Whitelist
$whitelist = new IpWhitelist(explode(',', WEBHOOK_ALLOWED_IPS));
if (!$whitelist->isAllowed($_SERVER['REMOTE_ADDR'])) {
    http_response_code(403);
    exit;
}

// Layer 2: HMAC Signature Verification
$verifier = new WebhookVerifier(WEBHOOK_SECRET, 300);
if (!$verifier->verify()) {
    exit; // verify() already sent the response
}

// Layer 3: Replay Protection
$webhookId = $_SERVER['HTTP_X_WEBHOOK_ID'] ?? '';
$replay = new ReplayProtection(300);
if (!empty($webhookId) && $replay->isDuplicate($webhookId)) {
    http_response_code(409);
    echo json_encode(['error' => 'Duplicate webhook']);
    exit;
}

// Layer 4: Payload Validation
$rawBody = file_get_contents('php://input');
$data = json_decode($rawBody, true);
if (json_last_error() !== JSON_ERROR_NONE) {
    http_response_code(400);
    echo json_encode(['error' => 'Invalid JSON']);
    exit;
}

$validator = new PayloadValidator();
if (!$validator->validatePublishPayload($data)) {
    http_response_code(422);
    echo json_encode(['errors' => $validator->getErrors()]);
    exit;
}

// All checks passed — process the webhook
$result = processWebhook($data);
echo json_encode($result);

Four layers of defense, each catching a different class of attack. The order matters: IP check is cheapest (no crypto), so it runs first. HMAC verification is next (one hash computation). Replay check is third (one file lookup). Payload validation is last (most CPU-intensive). If an attack fails at layer 1, we never waste resources on layers 2-4.

Error Handling and Logging

When a webhook fails, you need to know why — but you also need to be careful about what you log. Never log the secret key, the full signature, or the complete request body (which might contain sensitive content). Log enough to diagnose the issue without creating a security risk in your log files.

Our logging approach records: timestamp, IP address, truncated User-Agent, the rejection reason, and the webhook ID if present. We rotate logs daily and keep 30 days of history. In production, this has been invaluable for identifying patterns — like the time we noticed 400 signature failures from a single IP in one hour, which turned out to be a bot scanning for open webhook endpoints.

Key Rotation

Secrets should be rotated periodically. We rotate webhook secrets every 90 days. The rotation process supports a brief overlap period where both the old and new secret are accepted:

// During rotation, check both secrets
$secrets = [WEBHOOK_SECRET_CURRENT, WEBHOOK_SECRET_PREVIOUS];
$verified = false;

foreach ($secrets as $secret) {
    $verifier = new WebhookVerifier($secret, 300);
    if ($verifier->verifyWithoutExit()) {
        $verified = true;
        break;
    }
}

if (!$verified) {
    http_response_code(401);
    exit;
}

This allows you to update the secret on the sender side without coordinating an exact simultaneous change with the receiver. Deploy the new secret to the receiver first (accepting both old and new), then update the sender, then remove the old secret from the receiver after confirming everything works.

Webhook security is one of those things that feels like overkill until the day someone finds your endpoint. The implementation above adds roughly 2ms of overhead per request. That is a small price for knowing that every piece of content entering your site through an API was authorized.