В crypto и payments-интеграциях почти каждая команда довольно быстро доходит до webhook или callback-модели.
Провайдер присылает событие о депозите, изменении статуса invoice, выплате, KYC-результате или внутреннем wallet event. На раннем этапе всё выглядит просто: подняли endpoint, приняли JSON, обработали статус, ответили 200 OK.
Но как только система начинает работать на реальных деньгах, возникает неприятный вопрос: откуда backend знает, что входящий callback действительно отправил доверенный источник, а не кто угодно из интернета?
Именно здесь начинается тема подписи webhook. И именно здесь многие интеграции делают опасную ошибку: добавляют “какую-нибудь проверку”, которая выглядит убедительно в коде, но почти не снижает реальный риск.
Ниже — практический разбор, как делать проверку webhook signatures в crypto API так, чтобы она действительно защищала pipeline, а не создавала ложное чувство безопасности.
Почему IP allowlist и secret в query string — слабая защита
Когда команда спешит запустить интеграцию, обычно пробуют один из таких вариантов:
- проверять только IP источника;
- добавлять секрет в URL вроде
/callback?token=...; - доверять Basic Auth без отдельной подписи тела;
- считать, что раз endpoint “сложно угадать”, этого достаточно.
Проблема в том, что для платежных и crypto callbacks этого мало.
Что может пойти не так
- IP источника меняется — особенно если провайдер использует CDN, несколько регионов или облачную маршрутизацию.
- Секрет в query string утекает в логи reverse proxy, APM, error trackers и access logs.
- Basic Auth не защищает целостность payload — вы знаете, кто открыл соединение, но не проверяете, что тело запроса не было подменено на промежуточном участке.
- “Скрытый URL” не является контролем безопасности вообще.
Если webhook влияет на баланс, статус invoice, активацию payout или изменение ledger, нужна защита именно уровня аутентичности и целостности сообщения.
Что на самом деле должна гарантировать подпись callback
Нормальная схема подписи должна отвечать на три вопроса:
- Отправитель подлинный?
- Тело не было изменено по дороге?
- Старый запрос нельзя безопасно воспроизвести повторно как новый?
Иными словами, подпись должна закрывать три класса угроз:
- spoofing;
- tampering;
- replay.
Очень важно: HMAC сам по себе обычно закрывает только первые два пункта. Для replay protection нужны ещё timestamp, tolerance window и idempotency/dedup layer.
Практически рабочая схема
Если говорить без лишней магии, самый удобный и надёжный baseline для webhook callbacks — это:
- общий secret на интеграцию;
- подпись через
HMAC-SHA256; - подпись считается по сырому request body;
- в headers передаются
timestamp,signature, иногдаevent-id; - сервер проверяет:
- допустимость времени;
- корректность HMAC;
- отсутствие replay/duplicate по event id.
Пример набора заголовков:
X-Callback-Timestamp: 1712474400X-Callback-Signature: sha256=<hex>X-Event-Id: evt_01HT...
А canonical string для подписи может выглядеть так:
<timestamp>.<raw_body>
Это лучше, чем подписывать только body, потому что timestamp становится частью защищённого материала и не может быть подменён без поломки подписи.
Ошибка №1: подписывать уже распарсенный JSON
Это одна из самых частых и самых дорогих ошибок.
Команда получает JSON, десериализует его в объект, потом сериализует обратно и уже эту строку подписывает или сравнивает. Кажется логичным, но на практике это ломает верификацию.
Почему:
- JSON допускает разный порядок полей;
- пробелы, переносы строк и форматирование могут отличаться;
- числа и строки иногда сериализуются по-разному в разных языках и SDK;
- некоторые middleware меняют body ещё до вашей бизнес-логики.
Подпись нужно считать по raw body bytes ровно в том виде, в каком их прислал провайдер.
Если в приложении есть middleware, которое читает body раньше, надо либо сохранить сырой буфер, либо использовать обработчик, который явно забирает raw bytes до любого JSON parsing.
Ошибка №2: обычное сравнение строк вместо constant-time compare
Даже когда HMAC считается правильно, иногда дальше делают:
if provided == expected {
// ok
}
Для внутренних API это часто проходит незаметно, но для security-sensitive callback endpoint лучше использовать constant-time compare. Это базовая гигиена, которая снижает риск timing side-channel на проверке подписи.
В Go это обычно hmac.Equal, в Node.js — crypto.timingSafeEqual.
Ошибка №3: нет защиты от replay
Допустим, злоумышленник или промежуточная система каким-то образом получила валидный webhook целиком: headers, timestamp, body, signature.
Если backend проверяет только HMAC и больше ничего, такой запрос можно воспроизвести повторно. И если downstream-логика не идемпотентна, последствия будут неприятными:
- повторный crediting депозита;
- повторная смена статуса;
- повторная постановка payout job;
- лишние уведомления клиенту;
- путаница в audit trail.
Поэтому кроме HMAC нужны ещё два слоя.
1. Timestamp window
Например, принимать только запросы не старше 5 минут.
Если abs(now - timestamp) > 300, запрос отклоняется.
2. Event deduplication
Даже валидные запросы с одинаковым event_id должны быть безопасны при повторной доставке.
На практике это означает:
- хранить
event_id/external_event_id; - помечать обработку как idempotent;
- разделять
accepted duplicateиnew event; - не делать финансовый side effect дважды.
Подпись не отменяет идемпотентность. Они работают вместе.
Базовый flow проверки на сервере
Хороший серверный обработчик обычно выглядит так:
- Считать raw body.
- Достать
timestamp,signature,event_idиз headers. - Проверить, что обязательные headers присутствуют.
- Проверить формат timestamp.
- Проверить time window.
- Посчитать
expected_hmac(secret, timestamp + "." + raw_body). - Сравнить подписи constant-time способом.
- Проверить replay/dedup по
event_id. - Только после этого передать событие в бизнес-логику.
- Зафиксировать результат:
accepted,duplicate,invalid_signature,expired,missing_headers.
Очень важно, что проверка подписи должна происходить до любой финансовой или статусной логики.
Практический пример на Go
Ниже — минимальный каркас обработчика, который можно адаптировать под Sassoft-подобный API.
package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"strconv"
"strings"
"time"
)
func VerifyCallback(secret string, r *http.Request, now time.Time) ([]byte, error) {
rawBody, err := io.ReadAll(r.Body)
if err != nil {
return nil, ErrReadBody
}
tsHeader := r.Header.Get("X-Callback-Timestamp")
sigHeader := r.Header.Get("X-Callback-Signature")
if tsHeader == "" || sigHeader == "" {
return nil, ErrMissingHeaders
}
tsUnix, err := strconv.ParseInt(tsHeader, 10, 64)
if err != nil {
return nil, ErrInvalidTimestamp
}
ts := time.Unix(tsUnix, 0)
if now.Sub(ts) > 5*time.Minute || ts.Sub(now) > 5*time.Minute {
return nil, ErrExpiredSignature
}
payload := tsHeader + "." + string(rawBody)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(payload))
expected := mac.Sum(nil)
providedHex := strings.TrimPrefix(sigHeader, "sha256=")
provided, err := hex.DecodeString(providedHex)
if err != nil {
return nil, ErrInvalidSignature
}
if !hmac.Equal(provided, expected) {
return nil, ErrInvalidSignature
}
return rawBody, nil
}
Это не вся интеграция, но правильный security baseline уже здесь есть:
- raw body;
- timestamp;
- HMAC-SHA256;
- constant-time compare;
- tolerance window.
Дальше поверх этого нужен idempotency store по event_id.
Где хранить dedup state
В production лучший выбор обычно зависит от архитектуры.
Вариант 1. PostgreSQL
Подходит, если callback volume умеренный и бизнес-логика уже сидит в Postgres.
Пример подхода:
- таблица
callback_events; - уникальный индекс на
(provider, external_event_id); - вставка через
INSERT ... ON CONFLICT DO NOTHING; - если conflict есть — событие уже было.
Это простой и понятный путь.
Вариант 2. Redis
Подходит, если нужен быстрый TTL-based dedup и очень дешёвая запись на горячем ingress.
Например:
- key:
cb:{provider}:{event_id} SET key 1 NX EX 86400
Если SET NX не прошёл — событие уже видели.
Но для финансовых операций Redis-only dedup не всегда достаточно как единственный слой, потому что TTL и volatile memory не заменяют audit trail.
Вариант 3. Комбинация ingress cache + persistent journal
Хороший production-компромисс:
- Redis/queue layer для быстрого первичного dedup;
- Postgres journal для долговременной трассировки и расследований.
Что логировать при проверке webhook signature
Если security-проверка не даёт нормального operational сигнала, расследования будут мучительными.
Полезно логировать и метрики, и структурированные поля.
В логах
providerevent_idsignature_statustimestamp_skew_secondsrequest_idsource_ipendpointdedup_hit=true|false
В метриках
callback_verify_total{provider,result}callback_signature_failures_total{provider,reason}callback_replay_rejected_total{provider}callback_timestamp_skew_secondscallback_duplicate_total{provider}
Тогда команда видит не просто “что-то ломается”, а конкретно:
- провайдер шлёт некорректные подписи;
- у вас поехали часы;
- кто-то льёт мусор на endpoint;
- вырос duplicate/replay rate.
Clock skew — недооценённая причина ложных отказов
Если вы включили timestamp window, но не следите за временем на хостах, можно случайно устроить себе outage на ровном месте.
Типичный сценарий:
- webhook подписан корректно;
- провайдер отправил его вовремя;
- но ваш сервер отстаёт или спешит на несколько минут;
- валидные callbacks начинают падать как
expired.
Поэтому для callback security важно не только приложение, но и инфраструктурная гигиена:
- NTP / chrony должен быть исправен;
- drift времени должен мониториться;
- tolerance window должен быть реалистичным, а не 15 секунд “из соображений строгости”.
В большинстве production-интеграций окно 3–5 минут — нормальный компромисс.
Нужно ли шифровать сам payload
Обычно — нет.
Для webhook callbacks почти всегда достаточно:
- HTTPS/TLS для транспорта;
- HMAC для аутентичности и целостности;
- replay protection;
- idempotent processing.
Шифрование payload поверх TLS имеет смысл только в специфичных регуляторных или high-sensitivity сценариях. В типичном crypto/payments API основная проблема не в том, что кто-то увидит JSON, а в том, что кто-то попытается подделать или повторно воспроизвести событие.
Как делать secret rotation без простоя
Ещё одна практическая тема, которую часто забывают до первого инцидента.
Если у вас один активный secret и жёсткая замена “в моменте”, интеграция легко ломается во время ротации.
Рабочая схема обычно такая:
- backend поддерживает current secret и previous secret;
- при проверке подписи пытается сначала current, потом previous;
- провайдер переключается на новый secret;
- после переходного окна previous удаляется.
Для крупных интеграций полезно ещё вести secret_version, чтобы видеть, на каком ключе пришёл запрос.
Что делать, если провайдер не поддерживает подписи вообще
Такое всё ещё встречается.
Если повлиять на формат нельзя, тогда минимальный practical fallback:
- HTTPS обязателен;
- mTLS — если реально возможно;
- жёсткий IP allowlist как дополнительный, но не единственный слой;
- отдельный secret header хотя бы для аутентификации клиента;
- очень строгая идемпотентность и reconciliation downstream;
- отдельный alerting на аномалии callback traffic.
Это хуже, чем полноценная подпись, но лучше, чем просто публичный endpoint без контроля.
Минимальный production checklist
Если нужен короткий, но рабочий checklist для Sassoft-подобного callback ingress, я бы фиксировал такой baseline:
- Проверка идёт по raw request body
- В подпись включён timestamp
- Используется HMAC-SHA256
- Сравнение подписи constant-time
- Есть tolerance window на replay
- Есть dedup/idempotency по
event_id - Проверка происходит до business side effects
- Есть structured logs и метрики по причинам отказа
- Есть secret rotation без жёсткого cutover
- Мониторится clock skew и duplicate rate
Если хотя бы половины этого нет, интеграция обычно ещё не production-grade.
Вывод
Webhook signature в crypto API — это не декоративная галочка в integration checklist. Это кусок реального control plane, который защищает денежный pipeline от подделок, подмены и опасных повторов.
Сильная схема не ограничивается “посчитать HMAC”. Она объединяет:
- raw body verification;
- timestamp-based replay protection;
- idempotent event processing;
- нормальную observability;
- безопасную ротацию secret.
Именно такая комбинация делает callback ingress не просто рабочим, а надёжным для production-нагрузки и реальных денег.