Skip to content

Latest commit

 

History

History
297 lines (217 loc) · 8.88 KB

File metadata and controls

297 lines (217 loc) · 8.88 KB

Webhook Signature Verification

ComfinoPay sends webhook requests to your shop to notify about order status changes and other events. All webhooks are signed with a CR-Signature header to prove they originate from ComfinoPay and haven't been tampered with.

This document explains how to verify webhook signatures using the WebhookSignatureVerifier class provided by the API client.

Overview

When ComfinoPay sends a webhook to your endpoint:

  1. The server computes a CR-Signature header:

    CR-Signature: SHA3-256(apiKey . requestBody)
    
  2. Your shop receives the request and verifies the signature using the same API key.

  3. If the signature matches, you can trust the request came from ComfinoPay.

Setup

Create a verifier instance

WebhookSignatureVerifier is stateless — it takes no constructor arguments, and the API key is passed per call instead of being bound at construction time:

use Comfino\Auth\WebhookSignatureVerifier;

$verifier = new WebhookSignatureVerifier();

Verify an incoming request

// Get the raw POST body (not parsed JSON, but raw bytes).
$rawBody = file_get_contents('php://input');

// Get the signature header.
$signature = $_SERVER['HTTP_CR_SIGNATURE'] ?? '';

// Verify: Empty signature or API key are rejected outright — no exception is thrown.
if ($verifier->verify($signature, 'your-api-key', $rawBody)) {
    // Signature is valid — parse and process the webhook.
    $payload = json_decode($rawBody, true);
    handleWebhook($payload);
} else {
    // Signature is invalid, or the signature/API key was empty — reject the request.
    http_response_code(403);
    exit('Forbidden');
}

How verification works

The verifier uses timing-safe comparison to prevent timing attacks. The real implementation, from src/Auth/WebhookSignatureVerifier.php:

final class WebhookSignatureVerifier
{
    public function verify(string $signature, string $apiKey, string $payload): bool
    {
        if ($signature === '' || $apiKey === '') {
            return false;
        }

        return hash_equals(hash('sha3-256', $apiKey . $payload), $signature);
    }
}

Note this is a plain SHA3-256 hash of the API key concatenated with the payload — not an HMAC. An empty signature or API key fails closed rather than degrading to "hash of the payload alone", which would let anyone forge a valid signature.

Timing-safe comparison means the comparison time is independent of where the signatures differ. This prevents attackers from using timing measurements to guess the correct signature byte-by-byte.

Implementation patterns

Framework integration (PSR-7)

If your framework provides PSR-7 request objects:

use Psr\Http\Message\ServerRequestInterface;

function handleComfinoWebhook(ServerRequestInterface $request)
{
    // Get raw body.
    $body = (string) $request->getBody();

    // Get signature header (case-insensitive).
    $signature = $request->getHeaderLine('CR-Signature');

    // Verify.
    $verifier = new WebhookSignatureVerifier();
    if (!$verifier->verify($signature, 'your-api-key', $body)) {
        return $response->withStatus(403);
    }

    // Parse and process.
    $payload = json_decode($body, associative: true);
    // ... process payload ...

    return $response->withStatus(200);
}

Multiple API keys (sandbox + production)

If you run both sandbox and production endpoints on the same server:

$verifier = new WebhookSignatureVerifier();

$verifyWithMultipleKeys = function(string $body, string $signature, array $apiKeys) use ($verifier): bool {
    foreach ($apiKeys as $key) {
        if ($verifier->verify($signature, $key, $body)) {
            return true; // Matched one of the keys.
        }
    }
    return false;
};

$apiKeys = [
    'sandbox' => 'sandbox_key_...',
    'production' => 'live_key_...',
];

$verified = $verifyWithMultipleKeys(
    $rawBody,
    $signatureHeader,
    array_values($apiKeys)
);

if (!$verified) {
    http_response_code(403);
    exit('Forbidden');
}

Edge cases and best practices

Use raw body, not parsed JSON

Incorrect:

// WRONG — JSON parsing re-encodes, changing byte sequence.
$parsed = json_decode(file_get_contents('php://input'), assoc: true);
$rawBody = json_encode($parsed); // Different bytes = different hash!

Correct:

// RIGHT — Use the exact bytes received.
$rawBody = file_get_contents('php://input');
$verifier->verify($signature, $apiKey, $rawBody); // Verifies against original bytes.

// Parse only after verification.
$payload = json_decode($rawBody, true);

Handle missing signature header

Always check that the signature header is present:

$signature = $_SERVER['HTTP_CR_SIGNATURE'] ?? null;

if ($signature === null) {
    // Reject unsigned requests.
    http_response_code(400);
    exit('Missing CR-Signature header');
}

$verified = $verifier->verify($signature, $apiKey, $rawBody);

Logging

Log webhook events for debugging:

// Log the attempt (before verification for diagnostics).
error_log("Webhook received: {$_SERVER['REQUEST_METHOD']} {$_SERVER['REQUEST_URI']}");

if ($verified) {
    error_log("Webhook verified: " . substr($signature, 0, 16) . '...');
    // Process webhook.
} else {
    error_log("Webhook verification FAILED. Expected signature: " .
        hash('sha3-256', $apiKey . $rawBody));
}

Replay attack prevention

Webhook signatures alone don't prevent replay attacks (same valid request sent multiple times). For protection, maintain a list of processed signature hashes:

// After successful verification.
$signatureHash = md5($signature); // Or store the signature itself.

// Check if we've already processed this.
if ($processedSignatures[$signatureHash] ?? false) {
    http_response_code(200); // Idempotent: return success but don't reprocess.
    exit;
}

// First time seeing this signature — process it.
$processedSignatures[$signatureHash] = true;
processWebhook($payload);

Store processed signatures with a TTL (e.g., 24 hours) in Redis or a database:

$redis = new Redis();
$signatureKey = "webhook:sig:" . $signature;

if ($redis->exists($signatureKey)) {
    // Already processed.
    http_response_code(200);
    exit;
}

// Process the webhook.
processWebhook($payload);

// Mark as processed for the next 24 hours.
$redis->setex($signatureKey, 86400, true);

Webhook timeout handling

Webhooks should be processed quickly. If your endpoint takes longer than ~30 seconds, ComfinoPay may timeout and retry. For long-running tasks:

// 1. Verify and queue the webhook.
if (!$verifier->verify($signature, $apiKey, $rawBody)) {
    http_response_code(403);
    exit;
}

// 2. Acknowledge the receipt immediately.
http_response_code(202); // Accepted

// 3. Queue for background processing (async).
Queue::push(new ProcessComfinoWebhook($payload));

Troubleshooting

"Signature verification failed"

Possible causes:

  1. Wrong API key — Ensure you're using the correct sandbox/production key.
  2. Body was modified — Check that no middleware (compression, encoding) altered the request body.
  3. Signature header case — The header name is CR-Signature (exact case on ComfinoPay's side, but HTTP headers are case-insensitive in most servers).
  4. Body read twice — If you read php://input before verification, the stream is consumed. Read it once and store it.

Debug:

// Print what we received.
echo "Body length: " . strlen($rawBody) . "\n";
echo "Signature header: " . $signature . "\n";

// Compute what we expect.
$expected = hash('sha3-256', 'your-api-key' . $rawBody);
echo "Expected: " . $expected . "\n";
echo "Received: " . $signature . "\n";
echo "Match: " . ($expected === $signature ? 'YES' : 'NO') . "\n";

"Already processed this webhook"

If you're seeing the same webhook multiple times:

  1. Check your deduplication logic — Ensure the key is unique and TTL is adequate.
  2. ComfinoPay retry logic — ComfinoPay may retry on timeout or 5xx errors. Your endpoint should be idempotent.
  3. Network issues — If a request times out but actually succeeds server-side, ComfinoPay won't retry.

Security considerations

  • Never log the API key — Don't include it in error messages or logs.
  • Never expose the signature — Don't return it in responses or logs (except for debugging in private logs).
  • Use HTTPS — Always receive webhooks over HTTPS; unencrypted HTTP can leak the body and signature.
  • Validate the signature first — Verify before parsing or processing the payload.
  • Implement replay protection — Store processed signatures to prevent accidental re-processing.

References