PHP: проверка HMAC-подписи webhook без SDK
Webhook нельзя проверять подписью уже разобранного JSON: повторная сериализация меняет пробелы, порядок полей и результат HMAC. Подписывать и проверять нужно исходную строку запроса.
Контракт запроса
Для своего webhook достаточно трёх заголовков:
X-Webhook-Timestamp— Unix time в секундах;X-Webhook-Signature— строкаsha256=<hex>;X-Webhook-Id— уникальный идентификатор события для защиты от дублей.
Строка для подписи собирается как timestamp.body. Timestamp ограничивает срок жизни запроса, а ID не даёт выполнить одно событие дважды внутри этого окна.
Проверка подписи
<?php
final class WebhookVerifier
{
public function verify(
string $rawBody,
string $timestamp,
string $signature,
string $secret,
?int $now = null,
): bool {
if (!ctype_digit($timestamp) || $secret === '') {
return false;
}
$now ??= time();
if (abs($now - (int) $timestamp) > 300) {
return false;
}
if (!str_starts_with($signature, 'sha256=')) {
return false;
}
$expected = 'sha256='.hash_hmac(
'sha256',
$timestamp.'.'.$rawBody,
$secret,
);
return hash_equals($expected, $signature);
}
}
hash_equals() нужен вместо обычного ===: он сравнивает строки за постоянное время и не раскрывает подпись по разнице во времени ответа.
Порядок в endpoint
$rawBody = file_get_contents('php://input') ?: '';
$timestamp = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$eventId = $_SERVER['HTTP_X_WEBHOOK_ID'] ?? '';
if (!$verifier->verify($rawBody, $timestamp, $signature, $secret)) {
http_response_code(401);
exit;
}
if ($eventId === '') {
http_response_code(400);
exit;
}
$payload = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
$handled = $eventStore->transaction(function () use (
$eventStore,
$handler,
$eventId,
$payload,
): bool {
if (!$eventStore->claim($eventId)) {
return false;
}
$handler->handle($payload);
return true;
});
http_response_code($handled ? 200 : 204);
Проверка HMAC идёт до json_decode(). Метод claim() делает INSERT в таблицу с уникальным индексом по event ID и возвращает false при конфликте. Обычный SELECT перед INSERT не защищает от двух одновременных запросов.
Повтор после ошибки
Claim и локальные изменения выполняются в одной транзакции: exception откатывает оба. Необратимый внешний эффект отправляется через outbox и тоже получает event ID как ключ идемпотентности.
Как формируется подпись
$body = json_encode($event, JSON_THROW_ON_ERROR);
$timestamp = (string) time();
$signature = 'sha256='.hash_hmac(
'sha256',
$timestamp.'.'.$body,
$secret,
);
// Отправить ровно $body, без повторного json_encode().
Секрет хранится в переменной окружения или secret storage. Его нельзя класть в payload, URL, exception message и access log.
Три обязательных теста
$body = '{"event":"invoice.paid","id":42}';
$timestamp = '1780992000';
$secret = 'test-secret';
$signature = 'sha256='.hash_hmac('sha256', $timestamp.'.'.$body, $secret);
self::assertTrue(
$verifier->verify($body, $timestamp, $signature, $secret, 1780992000),
);
self::assertFalse(
$verifier->verify($body.' ', $timestamp, $signature, $secret, 1780992000),
);
self::assertFalse(
$verifier->verify($body, $timestamp, $signature, $secret, 1780992301),
);
Первый тест фиксирует рабочую подпись, второй ловит изменение body даже на один байт, третий проверяет окно в пять минут.