JekCMS Webhook Security: HMAC Signing and Verification in PHP

A practical guide to webhook security in JekCMS: how the built-in HMAC-SHA256 signature check works, why hash_equals matters, and how to add optional hardening — replay protection, IP allowlisting, payload validation — on top of it yourself.

JekCMS Webhook Security: HMAC Signing and Verification in PHP

A practical guide to webhook security in JekCMS: how the built-in HMAC-SHA256 signature check works, why hash_equals matters, and how to add optional hardening — replay protection, IP allowlisting, payload validation — on top of it yourself.

Webhooks Are Open Doors — Secure Them

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

JekCMS's answer to that is a single, solid layer: HMAC-SHA256 signature verification on every webhook request. It's not a multi-layer security suite — it's one well-implemented check that closes the obvious hole. This article covers exactly what that check does, and what you can add on top of it yourself if your setup needs more.

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.

How JekCMS Verifies Incoming Webhooks

JekCMS's webhook endpoint checks the X-Webhook-Signature header against a hash computed from the raw request body and your N8N_WEBHOOK_SECRET:

<?php
$rawBody = file_get_contents('php://input');

$signatureHeader = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
if (strpos($signatureHeader, 'sha256=') !== 0) {
    http_response_code(401);
    exit;
}
$receivedSignature = substr($signatureHeader, 7);

$expectedSignature = hash_hmac('sha256', $rawBody, N8N_WEBHOOK_SECRET);

if (!hash_equals($expectedSignature, $receivedSignature)) {
    http_response_code(401);
    exit;
}

// Signature verified — process the payload

That's the whole check: one hash computation, one timing-safe comparison. No timestamp window, no replay store, no IP allowlist baked in. If the signature doesn't match, the request is rejected before it touches your business logic.

Why hash_equals Matters

The comparison uses hash_equals() instead of ===. This is not a style preference — it's 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 — a timing attack. hash_equals() always takes the same amount of time regardless of how many characters match, eliminating that side channel completely.

What This Layer Doesn't Cover — And How to Add It Yourself

A single signature check stops the most common attack (an attacker who found your URL but doesn't know your secret). It doesn't, by itself, protect against a captured request being replayed, and it doesn't restrict which IPs can reach the endpoint. If your setup needs that extra hardening, none of it is built in — but all of it is straightforward to add in the same handler.

Optional: Timestamp-Based Replay Protection

If you want to reject old, captured requests even when the signature is valid, have your sender include a timestamp in the signed content and check its age on the way in:

// Sender: include a timestamp in what gets signed
$timestamp = time();
$signedContent = $timestamp . '.' . $jsonBody;
$signature = hash_hmac('sha256', $signedContent, $secret);
// send $timestamp and $signature as separate headers

// Receiver: reject anything older than a few minutes
$age = abs(time() - $timestamp);
if ($age > 300) {
    http_response_code(401);
    exit;
}
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
if (!hash_equals($expected, $receivedSignature)) {
    http_response_code(401);
    exit;
}

This requires coordination on both ends — your sender (an n8n workflow, for example) needs to include and sign the timestamp the same way your receiver expects it.

Optional: IP Allowlisting

If your webhook sender always comes from a known, stable IP or range — an n8n instance you control, for instance — you can add a basic allowlist check before the signature check runs, so requests from unexpected addresses are rejected without spending any CPU on hashing:

function ip_in_range(string $ip, string $range): bool {
    if (strpos($range, '/') === false) {
        return $ip === $range;
    }
    [$subnet, $bits] = explode('/', $range);
    $ipLong = ip2long($ip);
    $subnetLong = ip2long($subnet);
    $mask = -1 << (32 - (int)$bits);
    return ($ipLong & $mask) === ($subnetLong & $mask);
}

$allowed = ['10.0.0.0/8', '203.0.113.50'];
$clientIp = $_SERVER['REMOTE_ADDR'];
$ok = false;
foreach ($allowed as $range) {
    if (ip_in_range($clientIp, $range)) { $ok = true; break; }
}
if (!$ok) {
    http_response_code(403);
    exit;
}

One warning: if your server sits behind a reverse proxy or CDN, $_SERVER['REMOTE_ADDR'] is the proxy's IP, not the original client's. In that case you need X-Forwarded-For or CF-Connecting-IP instead — and you should only trust those headers if you've confirmed all traffic actually passes through the proxy, since they can otherwise be spoofed.

Payload Validation Still Matters

A correctly signed request can still contain bad data. Whatever the webhook is meant to do — publish a post, attach media, trigger a scheduled task — validate the payload's shape and field lengths before it touches your database, and reject anything with unexpected fields rather than trying to filter dangerous ones out. A simple allowlist of expected keys, with type and length checks on each, catches the overwhelming majority of malformed or malicious payloads without much code.

Key Rotation

Rotate your webhook secret periodically. There's no built-in dual-secret grace period in JekCMS, so a rotation means a short coordinated window: update the secret in both the sender and JekCMS's environment configuration around the same time, and expect a brief gap where a request sent with the old secret would fail verification. For most integrations that's an acceptable trade-off for rotating a credential every few months; if you need a zero-downtime rotation, you'd extend the verification check above to accept two secrets during the transition.

Logging Without Leaking Secrets

When a webhook fails, you need to know why — but be careful about what you log. Never log the secret key, the full signature, or the complete request body if it might contain sensitive content. Logging the timestamp, source IP, a truncated user agent, and the rejection reason is usually enough to diagnose an issue: a burst of signature failures from one IP is almost always a bot scanning for open webhook endpoints, not a real integration problem.

The HMAC check above adds negligible overhead per request. That's a small price for knowing that content entering your site through an API was actually authorized to be there.

Order Today

One-time payment, lifetime access. Setup in 30 minutes.

View Pricing
  • Setup and live in 30 minutes
  • 14+ professional themes
  • n8n automation integration
  • Automatic SEO — Sitemap, Schema.org
  • iyzico payment support

Be the first to know

New features, release notes & CMS guides — a couple of emails a month, no spam.