Вебхуки в Laravel: приём и проверка подписи
Как принять вебхук в Laravel и не пустить в обработку поддельный запрос: проверка HMAC-подписи, защита от повторов и три ошибки, из-за которых подпись не сходится.

Внешний сервис — платёжка, GitHub, CRM — шлёт вам POST на публичный URL. Проблема в том, что этот URL знает не только сервис: любой, кто его подсмотрел, может отправить свой запрос и, например, пометить неоплаченный заказ как оплаченный. Единственная защита — подпись: отправитель считает HMAC от тела запроса своим секретом, кладёт результат в заголовок, а вы пересчитываете и сравниваете. Если не сходится — запрос в мусор.
Ниже — рабочая задача: принять вебхук в Laravel 11.x, проверить подпись по сырому телу и не дать одному и тому же событию обработаться дважды. Разберём, почему очевидное $request->all() здесь ломает всё, и где чаще всего теряется совпадение подписи.
Почему нельзя проверять подпись по $request->all()
Отправитель считает HMAC от точной последовательности байтов тела запроса. Как только вы обращаетесь к $request->all() или $request->input(), Laravel уже распарсил JSON в массив. Если вы потом сделаете json_encode() обратно, чтобы посчитать подпись, вы получите другую строку: другой порядок ключей, экранирование слешей, пробелы после двоеточий. Подпись не сойдётся, хотя данные те же.
Проверять нужно по сырому телу — тому, что реально пришло по сети. В Laravel оно доступно через $request->getContent().
Правило одной строки: подпись считается по сырому телу до любого парсинга. Как только вы тронули данные как массив, исходные байты уже потеряны.
Минимальный приём вебхука
Маршрут для вебхука выносят из группы web — там сидит CSRF-защита, которая внешний запрос без токена завернёт с 419. Кладите его в routes/api.php или в отдельную группу без VerifyCsrfToken.
// routes/api.php
use App\Http\Controllers\WebhookController;
Route::post('/webhooks/payment', [WebhookController::class, 'handle']);Секрет для проверки подписи держите в .env, не в коде:
# .env
PAYMENT_WEBHOOK_SECRET=YOUR_WEBHOOK_SECRET// config/services.php
'payment' => [
'webhook_secret' => env('PAYMENT_WEBHOOK_SECRET'),
],Проверка HMAC-подписи
Конкретная формула зависит от отправителя: чаще всего это hash_hmac('sha256', $rawBody, $secret), результат в hex. Заголовок с подписью тоже у каждого свой — смотрите документацию отправителя, чтобы узнать его имя и формат (hex, base64, с префиксом вроде sha256=).
// app/Http/Controllers/WebhookController.php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class WebhookController extends Controller
{
public function handle(Request $request): Response
{
// Сырое тело — именно по нему считается подпись
$rawBody = $request->getContent();
// Имя заголовка зависит от отправителя, сверьте в его документации
$providedSignature = $request->header('X-Signature');
$secret = config('services.payment.webhook_secret');
if (! $this->isValidSignature($rawBody, $providedSignature, $secret)) {
// Не раскрываем причину, просто отказываем
return response('Invalid signature', 401);
}
// Только теперь безопасно парсить
$payload = json_decode($rawBody, true);
// ... обработка события
return response('OK', 200);
}
private function isValidSignature(string $body, ?string $provided, ?string $secret): bool
{
if (empty($provided) || empty($secret)) {
return false;
}
$expected = hash_hmac('sha256', $body, $secret);
// Сравнение с защитой от timing-атак
return hash_equals($expected, $provided);
}
}Ключевой момент — hash_equals() вместо ===. Обычное сравнение строк завершается на первом несовпавшем символе, и по времени ответа теоретически можно подбирать подпись байт за байтом. hash_equals() сравнивает за постоянное время.
timing-атака
Атака по времени: злоумышленник измеряет, насколько дольше сервер обрабатывает почти верную подпись, и по миллисекундным разницам восстанавливает правильное значение. hash_equals() закрывает этот канал.
Защита от повторов
Подпись подтверждает, что запрос пришёл от отправителя. Но валидный запрос можно перехватить и отправить повторно — и если это «списать деньги», повтор нежелателен. Два уровня защиты:
- Идемпотентность по ID события. Почти каждый отправитель кладёт в тело уникальный
idсобытия. Запоминайте обработанные ID и пропускайте дубликаты. - Проверка свежести по timestamp. Если отправитель шлёт заголовок со временем и включает его в подпись, отклоняйте запросы старше нескольких минут — так перехваченный вчера запрос сегодня уже не сработает.
use Illuminate\Support\Facades\Cache;
// внутри handle(), после проверки подписи
$eventId = $payload['id'] ?? null;
if ($eventId) {
$cacheKey = 'webhook:payment:' . $eventId;
// add() вернёт false, если ключ уже есть — значит, дубликат
if (! Cache::add($cacheKey, true, now()->addDay())) {
// Уже обработали, отвечаем 200, чтобы отправитель не ретраил
return response('Already processed', 200);
}
}Кеш здесь — быстрый и допустимый вариант, но при его сбросе защита пропадает. Для операций с деньгами надёжнее уникальный индекс в БД по ID события: попытка вставить дубликат упрётся в constraint, и это переживёт перезапуск.
Три ошибки, из-за которых подпись не сходится
- Считаете подпись по перекодированному телу. Взяли
$request->all(), сделалиjson_encode()— получили другую строку. ТолькоgetContent(). - Формат подписи не тот. Отправитель отдаёт base64, а вы сравниваете с hex. Или в заголовке есть префикс
sha256=, который надо отрезать перед сравнением. Логируйте ожидаемую и полученную подписи на тестовом стенде, чтобы увидеть расхождение (в проде подписи в логи не пишите). - Тело изменил прокси. Nginx или балансировщик может дописать или переупаковать тело. Если локально подпись сходится, а за прокси нет — смотрите, не трогает ли он body.
Если не получилось
| Что видите | Причина | Что делать |
|---|---|---|
| Ответ 419 Page Expired | Маршрут попал под CSRF-защиту группы web | Перенести маршрут в api.php или исключить из VerifyCsrfToken |
| Подпись не совпадает, данные верные | Считаете HMAC по перекодированному телу | Использовать $request->getContent() |
| getContent() пустой | Тело уже прочитали ранее в middleware | Проверить, не вызывается ли all()/input() до контроллера |
| hash_equals: TypeError | Один из аргументов null | Проверить наличие заголовка и секрета до сравнения |
| Отправитель шлёт повторы | Вы отвечаете не 2xx или отвечаете медленно | Проверять подпись быстро, тяжёлую работу — в очередь |
Что дальше
Проверка подписи должна быть быстрой: отправители обычно ждут ответ за несколько секунд и ретраят при таймауте. Тяжёлую обработку — запись в БД, обращения к внешним API — выносите в очередь через dispatch(), а на вебхук отвечайте 200 сразу после проверки подписи и постановки задачи. Точные требования к таймауту, числу ретраев и формату подписи смотрите в документации конкретного отправителя — они у всех разные. Отдельная тема — валидация схемы тела после парсинга; здесь мы разбирали только приём и подпись.
Проверка рекламы на риски: Что в макете вызовет вопросы ФАС: превосходные степени, сравнения, обещания результата, маркировка.
Спросить за 40 ₽Источники: PHP Manual: hash_hmac, PHP Manual: hash_equals
Частые вопросы
А если у меня Laravel 10, код сработает?
Почему getContent() возвращает пустую строку?
Обязательно ли использовать hash_equals вместо обычного сравнения?
Какой заголовок содержит подпись и в каком формате?
Что вернуть в ответе, если подпись неверна?
Как защититься от повторной отправки перехваченного запроса?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.