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

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

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

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

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

- **Подделка (spoofing)** — фальшивый запрос, выдающий себя за xpayment.
- **Повтор (replay)** — настоящий, ранее перехваченный запрос, отправленный заново, чтобы повторить действие дважды.

xpayment защищает от подделки, **подписывая каждую доставку**. С повторами вы боретесь с помощью **идемпотентности** (см. [руководство по вебхукам](https://xpayment.kz/blog/webhooks)).

## Что даёт xpayment

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

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

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

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

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

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

```http
X-xPayment-Signature: aeafc0dcd86de4d4d376f525be2b1175520c2818c93938ec8d069957c63a373b
```

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

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

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

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

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

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

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

![Проверщик секрета вебхука — общий гид](https://xpayment.kz/images/blog/webhook_secret_checker.png)

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

```json
{"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)

```js
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

```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)

```python
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)](https://xpayment.kz/images/blog/webhook_secret_checker_step_5.png)

## Чек-лист

- Читайте **«сырые» байты тела** до разбора JSON.
- Используйте сравнение **за постоянное время** (`timingSafeEqual`, `hmac.Equal`, `hmac.compare_digest`).
- Отвечайте `401`, если подпись отсутствует или неверна.
- Дополняйте **идемпотентностью** по `delivery_id` для защиты от повторов.

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

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

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

- Используйте **длинное случайное** значение — не менее 32 случайных символов. Сгенерируйте его, а не придумывайте:

  ```bash
  openssl rand -hex 32
  ```

- Используйте **отдельный секрет для каждой подписки**, чтобы отзыв одного эндпоинта не затрагивал другие.
- **Пустой секрет не даёт реальной защиты** — тело всё равно «подписывается», но на известном пустом ключе, поэтому подпись может воспроизвести кто угодно. Всегда задавайте надёжный секрет.

### Хранение

- Храните секрет в **переменной окружения** или менеджере секретов — никогда не зашивайте его в код и не коммитьте в git.
- Держите его там, где обработчик вебхука может прочитать его в момент запроса; больше он нигде не нужен.

### Ротация

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

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

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

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

- **Только HTTPS** — никогда не открывайте эндпоинт вебхука по обычному HTTP.
- **Идемпотентность** — сохраняйте каждый `delivery_id` и игнорируйте повторы (защита от replay и случайной двойной обработки).
- **IP-фильтрация** (опционально) — ограничьте входящие запросы адресами серверов xpayment.

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

- [Вебхуки: как xpayment уведомляет ваш сервер](https://xpayment.kz/blog/webhooks) — полное руководство, формат payload и повторы
- [Подключение устройства и получение API-ключа](https://xpayment.kz/blog/device-setup) — необходимо, прежде чем принимать события о платежах

## Читайте также
- [Webhooks xpayment](https://xpayment.kz/blog/webhooks)
- [Удалённые платежи](https://xpayment.kz/blog/direct-payment)

---

Источник: https://xpayment.kz/blog/webhook-security
