Безопасность вебхуков: проверка подписи и защита секрета

Как xpayment подписывает каждый вебхук алгоритмом HMAC-SHA256, как проверять заголовок X-xPayment-Signature на Node, Go и Python, и как выбрать, хранить и менять секрет.

Почему важна безопасность вебхуков

Ваш эндпоинт вебхука — это публичный URL. Любой, кто его узнает, может отправить на него POST-запрос, который выглядит как уведомление о платеже. Если ваш сервер доверяет каждому входящему запросу, злоумышленник может подделать событие payment.completed и заставить вашу систему отгрузить заказ, который никто не оплачивал.

От чего нужно защищаться:

xpayment защищает от подделки, подписывая каждую доставку. С повторами вы боретесь с помощью идемпотентности (см. руководство по вебхукам).

Что даёт xpayment

При создании подписки вебхука вы можете задать секрет. После этого каждая доставка содержит заголовок X-xPayment-Signature с HMAC-SHA256 от тела запроса, вычисленным на ключе — вашем секрете.

Поскольку секрет знаете только вы и xpayment, корректная подпись доказывает, что запрос действительно пришёл от xpayment и что тело не было изменено по пути. В следующих разделах разбираем точную схему, проверку в коде и безопасное управление секретом.

Схема подписи

Каждая доставка подписывается алгоритмом HMAC-SHA256:

Компонент Значение
Алгоритм HMAC-SHA256
Ключ Секрет подписки (secret)
Сообщение Точные «сырые» байты тела запроса
Кодировка Hex в нижнем регистре
Заголовок X-xPayment-Signature

Заголовок выглядит так:

X-xPayment-Signature: aeafc0dcd86de4d4d376f525be2b1175520c2818c93938ec8d069957c63a373b

Две частые ошибки

1. Это «чистый» hex — без префикса sha256=.

Некоторые провайдеры вебхуков добавляют префикс (например, sha256=abc...). xpayment этого не делает. Значение заголовка — это hex-дайджест и ничего больше. Если ваш валидатор срезает префикс, совпадения не будет никогда.

2. Подписывайте байты ровно в том виде, в каком они пришли.

Подпись вычисляется по «сырому» телу. Если вы распарсите JSON и заново его сериализуете перед хешированием, малейшее отличие — изменённый порядок ключей, другой формат числа, лишние пробелы — даст совершенно другой хеш, и проверка провалится. Всегда сохраняйте и хешируйте исходные байты тела запроса.

Быстрая проверка

Чтобы проверить секрет, код не нужен — проверщик секрета вебхука вычисляет ожидаемую подпись для любого payload и секрета.

Проверщик секрета вебхука — общий гид

При фиксированных теле и секрете qwerty123 подпись детерминирована. Это тело:

{"event":"payment.refunded","event_version":"1","delivery_id":"c09a3647-7822-4899-8401-3857cbd5bf68","attempt_number":1,"source":"xpayment","payment_id":"65cd0d84-5f66-4763-94d2-1b8e1ba68347","merchant_order_id":"qweasdxzc","created_at":"2026-06-27T10:24:04.104055991+05:00"}

с секретом qwerty123 даёт:

aeafc0dcd86de4d4d376f525be2b1175520c2818c93938ec8d069957c63a373b

Если ваш код воспроизводит это значение — проверка реализована верно.

Проверка подписи

Пересчитайте HMAC по «сырому» телу и сравните с заголовком, используя сравнение за постоянное время (обычное == утекает информацию о времени, которая помогает подобрать подпись побайтно).

Node.js (Express)

const crypto = require("crypto")

function verify(rawBody, signature, secret) {
  const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex")
  const a = Buffer.from(signature || "", "hex")
  const b = Buffer.from(expected, "hex")
  return a.length === b.length && crypto.timingSafeEqual(a, b)
}

// Сохраняем «сырое» тело, чтобы хешировать именно полученные байты:
app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf } }))

app.post("/webhook", (req, res) => {
  const sig = req.get("X-xPayment-Signature")
  if (!verify(req.rawBody, sig, process.env.WEBHOOK_SECRET)) {
    return res.status(401).end()
  }
  // подпись верна — обрабатываем req.body
  res.status(200).json({ ok: true })
})

Go

func verify(rawBody []byte, signature, secret string) bool {
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write(rawBody)
	expected := hex.EncodeToString(mac.Sum(nil))
	return hmac.Equal([]byte(signature), []byte(expected))
}

func handler(w http.ResponseWriter, r *http.Request) {
	body, err := io.ReadAll(r.Body)
	if err != nil {
		w.WriteHeader(http.StatusBadRequest)
		return
	}
	if !verify(body, r.Header.Get("X-xPayment-Signature"), os.Getenv("WEBHOOK_SECRET")) {
		w.WriteHeader(http.StatusUnauthorized)
		return
	}
	// подпись верна — разбираем тело и обрабатываем
	w.WriteHeader(http.StatusOK)
}

Python (Flask)

import hmac, hashlib
from flask import request

def verify(raw_body: bytes, signature: str, secret: str) -> bool:
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature, expected)

@app.post("/webhook")
def webhook():
    # request.get_data() возвращает «сырые» байты — НЕ используйте здесь request.json
    if not verify(request.get_data(), request.headers.get("X-xPayment-Signature", ""), WEBHOOK_SECRET):
        return "", 401
    # подпись верна — теперь request.get_json() можно использовать безопасно
    return {"ok": True}, 200

Как получить ожидаемый хеш без кода

Чтобы просто узнать ожидаемую подпись для заданного payload и секрета, используйте проверщик секрета вебхука — шаг 5 показывает итоговый хеш, с которым должен совпасть ваш код:

Проверщик секрета вебхука — как получить ожидаемый хеш (шаг 5)

Чек-лист

Управление секретом

Секрет — единственное, что придаёт подписям смысл. Относитесь к нему как к паролю.

Выбор секрета

Хранение

Ротация

Меняйте секрет при подозрении на утечку или периодически — для гигиены. Обновите секрет в подписке, затем обновите значение, которое использует ваш эндпоинт.

Во время ротации есть короткое окно, когда уже отправленные доставки могут быть подписаны старым секретом. Для ротации без простоя временно принимайте либо старую, либо новую подпись, а старую отключите после того, как убедитесь, что весь трафик использует новый секрет.

Эшелонированная защита

Проверка подписи — ваш основной механизм. Поверх него добавьте:

Смотрите также