Skip to main content

Webhook Signatures

Core Forms signs outgoing webhook requests with HMAC-SHA256 so recipients can verify that payloads genuinely originated from your site. This prevents tampering and replay attacks.

How It Works

  1. You configure a webhook secret in the webhook action settings.
  2. When Core Forms sends a webhook, it signs the payload with the secret.
  3. Two headers are added to the request: X-CF-Signature and X-CF-Timestamp.
  4. The receiving server verifies the signature before processing.

Signature Headers

Header Format Description
X-CF-Signature sha256={hex_hash} HMAC-SHA256 of {timestamp}.{payload}
X-CF-Timestamp Unix timestamp string When the request was signed

Signing Process

The signature is computed over the concatenation of the timestamp and the raw JSON body, separated by a dot:

signed_payload = "{timestamp}.{json_body}"
signature = HMAC-SHA256(signed_payload, secret)
header = "sha256=" + signature
use Core_Forms\Workflows\WebhookSigner;

// Sign a payload
$payload = '{"name":"John","email":"john@example.com"}';
$secret  = 'whsec_a1b2c3d4e5f6...';

$headers = WebhookSigner::sign( $payload, $secret );
// Returns:
// [
//     'X-CF-Signature' => 'sha256=abc123...',
//     'X-CF-Timestamp' => '1712678400',
// ]

Replay Protection

The verification process includes a timestamp tolerance check (default: 5 minutes). Requests older than the tolerance window are rejected, preventing replay attacks:

// Tolerance is 300 seconds (5 minutes) by default
$is_valid = WebhookSigner::verify(
    $payload,
    $signature,    // From X-CF-Signature header
    $timestamp,    // From X-CF-Timestamp header
    $secret,
    300            // Tolerance in seconds
);

Generating a Secret

Core Forms generates cryptographically secure webhook secrets:

$secret = WebhookSigner::generate_secret();
// Returns a 64-character hex string (32 bytes)

Verification Examples

PHP

function verify_cf_webhook( string $payload, array $headers, string $secret ): bool {
    $signature = $headers['X-CF-Signature'] ?? '';
    $timestamp = $headers['X-CF-Timestamp'] ?? '';

    // Check timestamp freshness (5 minute tolerance)
    if ( abs( time() - (int) $timestamp ) > 300 ) {
        return false;
    }

    // Compute expected signature
    $signed_payload = $timestamp . '.' . $payload;
    $expected = 'sha256=' . hash_hmac( 'sha256', $signed_payload, $secret );

    // Constant-time comparison to prevent timing attacks
    return hash_equals( $expected, $signature );
}

// Usage in a webhook endpoint
$payload = file_get_contents( 'php://input' );
$headers = getallheaders();
$secret  = 'whsec_your_secret_here';

if ( ! verify_cf_webhook( $payload, $headers, $secret ) ) {
    http_response_code( 401 );
    exit( 'Invalid signature' );
}

$data = json_decode( $payload, true );
// Process the webhook...

Node.js

const crypto = require('crypto');

function verifyCFWebhook(payload, headers, secret) {
    const signature = headers['x-cf-signature'] || '';
    const timestamp = headers['x-cf-timestamp'] || '';

    // Check timestamp freshness (5 minute tolerance)
    const now = Math.floor(Date.now() / 1000);
    if (Math.abs(now - parseInt(timestamp, 10)) > 300) {
        return false;
    }

    // Compute expected signature
    const signedPayload = `${timestamp}.${payload}`;
    const expected = 'sha256=' +
        crypto.createHmac('sha256', secret)
              .update(signedPayload)
              .digest('hex');

    // Constant-time comparison
    return crypto.timingSafeEqual(
        Buffer.from(expected),
        Buffer.from(signature)
    );
}

// Express.js example
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
    const payload = req.body.toString();
    const secret = process.env.CF_WEBHOOK_SECRET;

    if (!verifyCFWebhook(payload, req.headers, secret)) {
        return res.status(401).send('Invalid signature');
    }

    const data = JSON.parse(payload);
    // Process the webhook...
    res.sendStatus(200);
});

Python

import hmac
import hashlib
import time

def verify_cf_webhook(payload: str, headers: dict, secret: str) -> bool:
    signature = headers.get('X-CF-Signature', '')
    timestamp = headers.get('X-CF-Timestamp', '')

    # Check timestamp freshness
    if abs(time.time() - int(timestamp)) > 300:
        return False

    # Compute expected signature
    signed_payload = f"{timestamp}.{payload}"
    expected = 'sha256=' + hmac.new(
        secret.encode(), signed_payload.encode(), hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(expected, signature)

Security Considerations

  • Store your webhook secret securely (environment variable, secrets manager).
  • Always use HTTPS for webhook endpoints.
  • Always use constant-time comparison (hash_equals, timingSafeEqual, hmac.compare_digest).
  • Reject requests outside the 5-minute tolerance window.
  • Log failed verification attempts for monitoring.

Related