Kravchenko

Web Lab

АудитБлогКонтакты

Kravchenko

Web Lab

Разрабатываем сайты и автоматизацию на современных фреймворках под ключ

Услуги
ЛендингМногостраничныйВизитка
E-commerceБронированиеПортфолио
Навигация
БлогКонтактыАудит
Обратная связь
+7 921 567-11-16
info@kravlab.ru
с 09:00 до 18:00

© 2026 Все права защищены

•

ИП Кравченко Никита Владимирович

•

ОГРНИП: 324784700339743

Политика конфиденциальности

Подпись и проверка вебхуков: защита от подделок и меньше инцидентов интеграций

Разработка и технологии30 января 2026 г.
Вебхуки удобны, но легко становятся входной точкой для мошенников и случайных инцидентов. Разбираемся, как внедрить подпись запросов (HMAC), защиту от повторов, ротацию ключей и логирование, чтобы снизить риски, упростить аудит и ускорить онбординг партнёров.
Подпись и проверка вебхуков: защита от подделок и меньше инцидентов интеграций

Оглавление

  • Зачем подписывать вебхуки, если есть TLS
  • Какая от этого выгода бизнесу
  • Минимальный рабочий формат подписи
  • Алгоритм HMAC: шаг за шагом
    • Почему нельзя доверять JSON-парсеру
  • Пример: приём вебхука и проверка подписи на Python (Flask)
  • Пример: проверка подписи в Node.js (Express) с «сырым» телом
  • Защита от повторов: метка времени и nonce
  • Ротация ключей и версия подписи
  • Подводные камни: тела запросов, прокси, кодировки
  • Логирование и диагностика
  • Тестирование: проверочные векторы
  • Альтернатива HMAC: асимметричные ключи
  • Чек-лист внедрения
  • Итоги

Зачем подписывать вебхуки, если есть TLS

TLS шифрует трафик и защищает от «подслушивания». Но он не доказывает, что отправитель — именно ваш партнёр или ваш собственный сервис. Любой, кто знает публичный URL вашего вебхука, может отправить запрос, похожий на настоящий. Это открывает путь к:

  • подделкам событий (например, «оплата прошла», хотя это не так);
  • повторным доставкам старых событий (replay) с целью повторить действие;
  • случайным ошибкам интеграции, когда тестовые данные «пролетают» в прод.

Подпись решает задачу подлинности и целостности сообщения: мы доказываем, что запрос сформирован отправителем, знающим секрет (или владеющим закрытым ключом), и что тело запроса не менялось по дороге.

Какая от этого выгода бизнесу

  • Меньше мошенничества и спорных ситуаций: сложнее подделать событие «успех платежа» или «начислить бонус».
  • Ниже нагрузка на поддержку: меньше инцидентов из-за некорректных интеграций.
  • Быстрее онбординг партнёров: есть понятные, автоматически проверяемые правила.
  • Проще аудит и соответствие требованиям безопасности: политика ключей, журналы проверок, проверяемые артефакты.

Минимальный рабочий формат подписи

Договоримся о заголовке с подписью. Простой и совместимый вариант — один заголовок Webhook-Signature с параметрами через запятую:

Пример:

Webhook-Signature: t=1719830400,kid=live-01,v1=Zb2w7iZbUq8pN1f1zXH1zHhU8v9q5EPL+o2tX1m7k/Q=

Где:

  • t — метка времени (в секундах UTC);
  • kid — идентификатор ключа для ротации;
  • v1 — подпись HMAC-SHA256 от строки ${t}.${raw_body} в Base64.

Можно расширить протокол, добавив nonce для защиты от повторов и v2 для новой схемы подписи, не ломая старую.

Алгоритм HMAC: шаг за шагом

Отправитель:

  1. Формирует тело запроса (строго определённой сериализацией, например, JSON без лишних пробелов),
  2. Берёт текущую метку времени t (например, Unix time),
  3. Собирает сообщение для подписи: байтовая конкатенация str(t).encode() + b'.' + raw_body,
  4. Считает HMAC-SHA256 с секретом по сообщению,
  5. Кодирует подпись в Base64, формирует заголовок Webhook-Signature.

Получатель:

  1. Читает сырое тело запроса (байты без преобразований),
  2. Парсит заголовок, вытаскивает t, kid, v1,
  3. Проверяет, что t попадает в окно допустимого времени (например, ±5 минут),
  4. По kid находит активный секрет, пересчитывает HMAC и сравнивает с v1 константным сравнением (без утечек времени),
  5. Дополнительно проверяет nonce (если используется) и уникальность события.

Почему нельзя доверять JSON-парсеру

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

Пример: приём вебхука и проверка подписи на Python (Flask)

Ниже — полностью рабочий пример. Он:

  • принимает POST на /webhook,
  • читает сырое тело без преобразований,
  • парсит заголовок Webhook-Signature,
  • проверяет окно времени, подпись и защиту от повторов на основе (timestamp, signature).
# requirements: flask==3.0.0
# Запуск: python app.py
# Тест: curl -X POST http://127.0.0.1:5000/webhook -H "Content-Type: application/json" \
#       -d '{"event":"ping"}' (без подписи вернёт 400)

import base64
import hmac
import hashlib
import time
from collections import OrderedDict
from typing import Dict

from flask import Flask, request, jsonify

app = Flask(__name__)

# Активные ключи (пример). В реальности храните в конфиге/секретах.
SECRETS: Dict[str, bytes] = {
    "live-01": b"supersecret_live_01",
}

# Окно допустимой метки времени в секундах
TIMESTAMP_TOLERANCE = 300  # 5 минут

# Простейший LRU для защиты от повторов: сохраняем N последних подписей с TTL
class AntiReplay:
    def __init__(self, capacity: int = 10000, ttl_seconds: int = 600):
        self.capacity = capacity
        self.ttl = ttl_seconds
        self._store = OrderedDict()

    def _evict(self):
        # удаляем просроченные записи
        now = time.time()
        keys_to_delete = []
        for k, (ts, _) in self._store.items():
            if now - ts > self.ttl:
                keys_to_delete.append(k)
            else:
                break  # так как OrderedDict по времени добавления
        for k in keys_to_delete:
            self._store.pop(k, None)
        # ограничение по размеру
        while len(self._store) > self.capacity:
            self._store.popitem(last=False)

    def seen(self, key: str) -> bool:
        self._evict()
        if key in self._store:
            return True
        self._store[key] = (time.time(), True)
        return False

anti_replay = AntiReplay()


def parse_signature_header(header: str):
    parts = [p.strip() for p in header.split(',') if p.strip()]
    data = {}
    for p in parts:
        if '=' in p:
            k, v = p.split('=', 1)
            data[k] = v
    if 't' not in data or 'v1' not in data or 'kid' not in data:
        raise ValueError('signature header missing fields')
    try:
        t = int(data['t'])
    except ValueError:
        raise ValueError('invalid t')
    return t, data['kid'], data['v1']


def compute_hmac_base64(secret: bytes, timestamp: int, raw_body: bytes) -> str:
    msg = str(timestamp).encode('utf-8') + b'.' + raw_body
    mac = hmac.new(secret, msg, hashlib.sha256).digest()
    return base64.b64encode(mac).decode('ascii')


def secure_compare(a: str, b: str) -> bool:
    # сравнение без утечек времени
    return hmac.compare_digest(a, b)


@app.post('/webhook')
def webhook():
    raw_body = request.get_data(cache=False)  # сырые байты
    sig_header = request.headers.get('Webhook-Signature', '')

    if not sig_header:
        return jsonify(error='missing signature'), 400

    try:
        t, kid, v1 = parse_signature_header(sig_header)
    except ValueError as e:
        return jsonify(error=str(e)), 400

    now = int(time.time())
    if abs(now - t) > TIMESTAMP_TOLERANCE:
        return jsonify(error='timestamp outside tolerance'), 400

    secret = SECRETS.get(kid)
    if not secret:
        return jsonify(error='unknown key id'), 400

    expected = compute_hmac_base64(secret, t, raw_body)

    if not secure_compare(expected, v1):
        return jsonify(error='invalid signature'), 400

    # простая защита от повторов: не принимать одну и ту же (t,v1) дважды
    replay_key = f"{t}:{v1}"
    if anti_replay.seen(replay_key):
        return jsonify(error='replay detected'), 409

    # На этом этапе запрос аутентичен. Обработка события:
    # Важно: используйте idempotency на уровне события (например, event_id).
    # Здесь просто эхо-ответ для демонстрации.
    return jsonify(status='ok', received_len=len(raw_body))


if __name__ == '__main__':
    app.run(debug=True)

Для локальной проверки подписи удобно написать генератор подписи, имитирующий отправителя:

# Генерация заголовка подписи для теста
import base64, hmac, hashlib, time

secret = b"supersecret_live_01"
body = b'{"event":"ping"}'
t = int(time.time())
msg = str(t).encode() + b'.' + body
sig = base64.b64encode(hmac.new(secret, msg, hashlib.sha256).digest()).decode('ascii')
header = f"Webhook-Signature: t={t},kid=live-01,v1={sig}"
print(header)

Пример: проверка подписи в Node.js (Express) с «сырым» телом

Главная ловушка в Node.js — стандартные парсеры превращают тело в объект и теряют исходные байты. Нужен middleware, который сохраняет «сырое» тело.

// package.json: { "type": "module" }
// npm i express raw-body
import express from 'express';
import getRawBody from 'raw-body';
import crypto from 'crypto';

const app = express();

// Middleware: читаем сырые байты и сохраняем на req.rawBody
app.use(async (req, res, next) => {
  try {
    req.rawBody = await getRawBody(req);
    next();
  } catch (e) {
    res.status(400).json({ error: 'invalid body' });
  }
});

const SECRETS = { 'live-01': Buffer.from('supersecret_live_01', 'utf8') };
const TOLERANCE = 300; // 5 минут

function parseSignatureHeader(h) {
  if (!h) throw new Error('missing header');
  const parts = h.split(',').map(p => p.trim()).filter(Boolean);
  const data = {};
  for (const p of parts) {
    const [k, v] = p.split('=');
    data[k] = v;
  }
  const t = parseInt(data.t, 10);
  if (!t || !data.v1 || !data.kid) throw new Error('invalid header');
  return { t, v1: data.v1, kid: data.kid };
}

function computeHmacBase64(secret, t, rawBody) {
  const msg = Buffer.concat([Buffer.from(String(t)), Buffer.from('.') , rawBody]);
  const mac = crypto.createHmac('sha256', secret).update(msg).digest();
  return mac.toString('base64');
}

function safeEqual(a, b) {
  const ab = Buffer.from(a);
  const bb = Buffer.from(b);
  if (ab.length !== bb.length) return false;
  return crypto.timingSafeEqual(ab, bb);
}

const seen = new Set();
function isReplay(key) {
  if (seen.has(key)) return true;
  seen.add(key);
  if (seen.size > 10000) {
    // Простая эвакуация для примера (в проде используйте TTL/Redis)
    const it = seen.values().next().value;
    seen.delete(it);
  }
  return false;
}

app.post('/webhook', (req, res) => {
  try {
    const { t, v1, kid } = parseSignatureHeader(req.headers['webhook-signature']);
    const now = Math.floor(Date.now() / 1000);
    if (Math.abs(now - t) > TOLERANCE) return res.status(400).json({ error: 'timestamp' });
    const secret = SECRETS[kid];
    if (!secret) return res.status(400).json({ error: 'unknown kid' });

    const expected = computeHmacBase64(secret, t, req.rawBody);
    if (!safeEqual(expected, v1)) return res.status(400).json({ error: 'signature' });

    const replayKey = `${t}:${v1}`;
    if (isReplay(replayKey)) return res.status(409).json({ error: 'replay' });

    return res.json({ status: 'ok', len: req.rawBody.length });
  } catch (e) {
    return res.status(400).json({ error: e.message });
  }
});

app.listen(3000, () => console.log('listening on :3000'));

Защита от повторов: метка времени и nonce

Метка времени ограничивает «срок жизни» подписи. Но если злоумышленник перехватил и тут же повторно отправил запрос — timestamp не поможет. Добавьте одноразовый идентификатор (nonce) или используйте связку (delivery_id, timestamp):

  • Отправитель генерирует nonce или delivery_id и включает его в тело и/или заголовки (например, Delivery-Id: ...).
  • Получатель хранит последние значения (в памяти, Redis, базе) и отклоняет дубликаты в окне 5–15 минут.

Не путайте с идемпотентностью: даже уникальный delivery может описывать событие, которое уже обработано. Храните event_id и делайте обработку идемпотентной.

Ротация ключей и версия подписи

Рано или поздно ключи меняются. План:

  1. Введите kid — идентификатор ключа. Храните несколько секретов на приёме.
  2. Добавьте новый ключ и начинайте подписывать им (новый kid). Старый держите активным, чтобы принимать старые доставки и ретраи.
  3. После «переходного окна» отключите старый ключ на приёме.
  4. Ведите журнал операций с ключами (кто, когда, зачем).

Версионирование подписи (например, v1/v2) пригодится, если изменится схема: иные алгоритмы, каноникализация, дополнительные поля. Пока поддерживайте обе, отдавая приоритет новой.

Подводные камни: тела запросов, прокси, кодировки

  • Сырые байты. Любое «автоматическое» преобразование тела (переносы, пробелы, порядок ключей) ломает верификацию. На приёмной стороне используйте доступ к необработанным байтам.
  • Компрессия. Если запрос сжат (gzip), подписывается именно сжатое тело. Иначе подпись не совпадёт. Проще — отключить компрессию в вебхуках или подписывать несжатое тело и не сжимать такие запросы.
  • Прокси и CDN. Они могут менять заголовки и даже тело. Для вебхуков лучше миновать «умные» преобразования, либо явно их отключать на пути.
  • Кодировки. Фиксируйте Content-Type: application/json; charset=utf-8 и реально используйте UTF‑8.
  • Очередность полей JSON. Если сериализуете JSON на отправке, выберите детерминированную сериализацию (например, сортировка ключей) и объявите это в документации.
  • Переигрывание тел. Не полагайтесь только на timestamp: добавьте nonce/delivery_id и хранилище недавних значений.

Продвинутый вариант — вместе с подписью передавать Content-Digest по RFC (хеш тела). Это помогает отладке, но не заменяет подпись.

Логирование и диагностика

  • Логируйте факт проверки: результат (ok/fail), причину отказа (timestamp, unknown_kid, signature_mismatch, replay), длину тела, kid, окно времени, но не сам секрет и не значение подписи целиком. Допустимо логировать первые несколько символов подписи для трассировки.
  • В ответах партнёру не раскрывайте детали (особенно точные причины отказа) — этого достаточно злоумышленнику.
  • В метриках считайте доли отказов по типам причин, p50/p95 времени проверки, частоту повторов.

Тестирование: проверочные векторы

Соберите «набор примеров» (fixtures):

  • тело запроса,
  • t, kid, секрет,
  • ожидаемая подпись.

Положите их в репозиторий рядом с кодом проверки и гоняйте как юнит‑тесты в CI. Это спасает от случайных изменений сериализации JSON и регрессий.

Альтернатива HMAC: асимметричные ключи

Если ко многим получателям надо транслировать вебхуки, или вы не хотите делиться секретом с каждым — рассмотрите асимметричные подписи (Ed25519, ECDSA):

  • У отправителя — закрытый ключ; у получателя — публичный.
  • Проще ротация и отзыв ключей: компрометация получателя не раскрывает ключ отправителя.
  • Минус — сложнее реализация и управление ключами, чуть выше CPU.

Для большинства B2B‑интеграций стартуйте с HMAC: просто, быстро и достаточно безопасно при аккуратной реализации.

Чек-лист внедрения

  • Согласован единый заголовок подписи: Webhook-Signature с t, kid, v1.
  • Подписываем строку ${t}.${raw_body}; используем HMAC-SHA256 + Base64.
  • На приёме читаем сырые байты; не меняем тело до проверки.
  • Проверяем окно по времени (например, ±5 минут).
  • Делаем сравнение подписи константным по времени.
  • Включаем защиту от повторов (nonce или delivery_id + хранилище недавних значений).
  • Документируем сериализацию JSON и заголовки (Content-Type, Content-Length).
  • Настроена ротация ключей через kid, хранится несколько активных секретов.
  • Есть тестовые векторы в репозитории и автотесты.
  • Логи и метрики не раскрывают секреты, но помогают расследованию.

Итоги

Подпись и проверка вебхуков — недорогая мера, которая закрывает реальные риски: подделки, непреднамеренные дубли и «шум» от нестабильных интеграций. Простая схема на HMAC, окно по времени и защита от повторов дают быстрый выигрыш: меньше инцидентов, меньше нагрузки на поддержку, выше доверие партнёров и спокойнее аудит. Начните с минимального формата и юнит‑тестов, а по мере роста добавьте ротацию ключей, версионирование и, при необходимости, асимметричную криптографию.


безопасностьвебхукиHMAC