Перейти к содержимому
Tutorial

Безопасность Webhook: подписи HMAC, идемпотентность и защита от повторного воспроизведения.

| 9 min read

Три шаблона кода предотвращают подмену полезных данных, дублирование доставок и повторение запросов веб-перехватчиков. Примеры Node.js с HMAC-SHA256 и проверкой меток времени.

Digital encryption padlock on a circuit board
Photo by Adi Goldstein on Unsplash

Ваш обработчик веб-перехватчика принимает запрос POST, анализирует JSON и запускает бизнес-логику. Это работает до тех пор, пока кто-нибудь отправляет поддельную полезную нагрузку на вашу конечную точку. Или до тех пор, пока поставщик не повторит попытку доставки и ваш куратор не взимает плату за доставку. клиент дважды. Или до тех пор, пока злоумышленник не запишет законный запрос и не воспроизведет его через шесть часов.

Это исправляют три средства защиты: проверка подписи HMAC, ключи идемпотентности и отклонение воспроизведения на основе временной метки. В этом руководстве рассматривается каждый из них с использованием рабочего кода Node.js.

Как работает подпись вебхука

Каждый крупный поставщик веб-перехватчиков (Stripe, GitHub, Shopify, Twilio) подписывает исходящие полезные данные с помощью HMAC-SHA256. Поставщик объединяет необработанное тело запроса с общим секретом, вычисляет хэш и отправляет этот хеш в заголовке. Ваш сервер пересчитывает хэш с тем же секретом и сравнивает его. Если хеши совпадают, полезная нагрузка является подлинной.

Формула подписания выглядит следующим образом:

HMAC-SHA256(secret, timestamp + "." + raw_body) = signature

Stripe отправляет подпись Stripe-Signature. GitHub отправляет его X-Hub-Signature-256. Алгоритм тот же; отличаются только имя и формат заголовка.

Проверка подписи веб-перехватчика Stripe в Node.js

Страйп Stripe-Signature заголовок содержит метку времени (t=) и один или несколько версий подписи (v1=). Вот как это проверить без Stripe SDK:

const crypto = require('crypto');

function verifyStripeSignature(payload, header, secret) {
  const parts = header.split(',');
  const timestamp = parts.find(p => p.startsWith('t=')).slice(2);
  const signature = parts.find(p => p.startsWith('v1=')).slice(3);

  const signedPayload = timestamp + '.' + payload;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex');

  if (expected !== signature) {
    throw new Error('Invalid signature');
  }

  // Replay protection: reject if older than 5 minutes
  const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
  if (age > 300) {
    throw new Error('Timestamp too old');
  }

  return JSON.parse(payload);
}

Ключевые детали: Stripe объединяет метку времени и необработанное тело с помощью разделителя точки. v1 подпись использует HMAC-SHA256. Проверка временной метки в конце обеспечивает защиту от повторного воспроизведения (подробно описано ниже).

Проверка подписи веб-перехватчика GitHub в Node.js

Формат GitHub проще. X-Hub-Signature-256 заголовок содержит sha256= подписан по шестнадцатеричному HMAC исходного тела:

const crypto = require('crypto');

function verifyGitHubSignature(payload, signatureHeader, secret) {
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex');

  const trusted = Buffer.from(expected, 'utf8');
  const untrusted = Buffer.from(signatureHeader, 'utf8');

  if (trusted.length !== untrusted.length) {
    throw new Error('Invalid signature');
  }

  if (!crypto.timingSafeEqual(trusted, untrusted)) {
    throw new Error('Invalid signature');
  }

  return JSON.parse(payload);
}

Уведомление crypto.timingSafeEqual. Стандарт === сравнение утечек информации о сроках. Злоумышленник может измерить время отклика, чтобы побайтово угадать правильную подпись. Сравнение в постоянное время устраняет этот вектор.

Вычисление подписей HMAC с помощью хеш-API botoi

Если вам нужно вычислить или проверить подписи HMAC-SHA256 из сценария, конвейера CI или бессерверной функции. без импорта crypto, используйте /v1/hash/hmac конечная точка:

curl -X POST https://api.botoi.com/v1/hash/hmac \\
  -H "Content-Type: application/json" \\
  -d '{
    "text": "1712345678.{\\"event\\":\\"payment_intent.succeeded\\",\\"amount\\":4999}",
    "key": "whsec_your_stripe_secret",
    "algorithm": "sha256"
  }'

Ответ:

{
  "success": true,
  "data": {
    "hmac": "a3f2b8c9d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0",
    "algorithm": "sha256"
  }
}

Это возвращает HMAC в шестнадцатеричной кодировке. Сравните его с заголовком подписи поставщика веб-перехватчика. Конечная точка поддерживает оба sha256 и sha512 алгоритмы.

Создайте промежуточное программное обеспечение Express для проверки HMAC.

Оберните проверку подписи в многоразовое промежуточное программное обеспечение, чтобы каждый маршрут веб-перехватчика был защищен без дублирования кода:

const crypto = require('crypto');

function webhookAuth(secret) {
  return (req, res, next) => {
    const signature = req.headers['x-webhook-signature'];
    const timestamp = req.headers['x-webhook-timestamp'];

    if (!signature || !timestamp) {
      return res.status(401).json({ error: 'Missing signature headers' });
    }

    // Step 1: Replay protection
    const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
    if (age > 300) {
      return res.status(401).json({ error: 'Request too old' });
    }

    // Step 2: HMAC verification
    const payload = JSON.stringify(req.body);
    const expected = crypto
      .createHmac('sha256', secret)
      .update(timestamp + '.' + payload)
      .digest('hex');

    const trusted = Buffer.from(expected, 'utf8');
    const untrusted = Buffer.from(signature, 'utf8');

    if (trusted.length !== untrusted.length ||
        !crypto.timingSafeEqual(trusted, untrusted)) {
      return res.status(401).json({ error: 'Invalid signature' });
    }

    next();
  };
}

// Mount on your webhook route
app.post('/webhooks/stripe', webhookAuth(process.env.STRIPE_WEBHOOK_SECRET), handler);

Это промежуточное программное обеспечение отклоняет запросы с отсутствующими заголовками, устаревшими временными метками или недействительными подписями до того, как вы обработчик запускается. 300-секундное окно соответствует допуску Stripe по умолчанию.

Идемпотентность: пропуск дублирующих поставок

Поставщики веб-перехватчиков повторяют неудачные доставки. Если ваш сервер вернул 500 после обработки события, но до отправляя ответ 200, провайдер снова отправляет то же событие. Без идемпотентности ваш обработчик запускает побочные эффекты дважды: двойные платежи, повторяющиеся электронные письма, повторяющиеся обновления инвентаря.

Исправление: сохраните идентификатор каждого обработанного события и пропустите события, которые вы видели раньше.

Идемпотентность в памяти (развитие)

const processedEvents = new Map(); // Use Redis in production

async function handleWebhook(req, res) {
  const eventId = req.body.id;

  // Check if already processed
  if (processedEvents.has(eventId)) {
    return res.status(200).json({ status: 'duplicate', eventId });
  }

  // Mark as processing before side effects
  processedEvents.set(eventId, Date.now());

  try {
    await processEvent(req.body);
    return res.status(200).json({ status: 'processed', eventId });
  } catch (err) {
    // Remove on failure so retries work
    processedEvents.delete(eventId);
    return res.status(500).json({ error: 'Processing failed' });
  }
}

The Map работает на местное развитие. В рабочей среде используйте общее хранилище, чтобы все экземпляры сервера увидеть тот же набор обработанных событий.

Идемпотентность, поддерживаемая Redis (производство)

const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);

async function handleWebhook(req, res) {
  const eventId = req.body.id;
  const lockKey = \`webhook:processed:\${eventId}\

SET NX является атомарным: только один процесс получает блокировку. 72-часовой TTL (259200 секунды) автоматически удаляет старые записи. Если обработка завершается неудачно, обработчик удаляет ключ, чтобы следующая повторная попытка могла быть успешной.

Защита от повторного воспроизведения: отклоняйте устаревшие запросы

Проверка HMAC подтверждает, что полезная нагрузка поступила от провайдера. Но действительный подписанный запрос, захваченный сетевой злоумышленник остается действительным навсегда, если вы не добавите проверку времени. Защита от повторного воспроизведения отклоняет полезную нагрузку с временными метками старше порогового значения.

function rejectStaleRequests(req, res, next) {
  const timestamp = parseInt(req.headers['x-webhook-timestamp'], 10);

  if (isNaN(timestamp)) {
    return res.status(401).json({ error: 'Missing timestamp' });
  }

  const now = Math.floor(Date.now() / 1000);
  const age = now - timestamp;

  // Reject anything older than 5 minutes or from the future
  if (age > 300 || age < -30) {
    return res.status(401).json({ error: 'Timestamp out of range' });
  }

  next();
}

30-секундный будущий допуск позволяет избежать незначительного отклонения тактовой частоты между вашим сервером и провайдером. что-нибудь старше 5 минут или более 30 секунд в будущем отклоняется.

Объедините все три в одно промежуточное программное обеспечение

Вот полное промежуточное программное обеспечение, которое последовательно выполняет все три проверки: метку времени, HMAC, затем идемпотентность.

const crypto = require('crypto');
const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);

function secureWebhook(secret) {
  return async (req, res, next) => {
    const signature = req.headers['x-webhook-signature'];
    const timestamp = req.headers['x-webhook-timestamp'];
    const eventId = req.body?.id;

    // 1. Reject missing headers
    if (!signature || !timestamp || !eventId) {
      return res.status(401).json({ error: 'Missing required headers or event ID' });
    }

    // 2. Replay protection: reject stale requests
    const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
    if (age > 300 || age < -30) {
      return res.status(401).json({ error: 'Timestamp out of range' });
    }

    // 3. HMAC verification
    const payload = JSON.stringify(req.body);
    const expected = crypto
      .createHmac('sha256', secret)
      .update(timestamp + '.' + payload)
      .digest('hex');

    const trusted = Buffer.from(expected, 'utf8');
    const untrusted = Buffer.from(signature, 'utf8');

    if (trusted.length !== untrusted.length ||
        !crypto.timingSafeEqual(trusted, untrusted)) {
      return res.status(401).json({ error: 'Invalid signature' });
    }

    // 4. Idempotency: skip duplicates
    const lockKey = \`webhook:processed:\${eventId}\

Порядок имеет значение. Timestamp — самая дешевая проверка (без ввода-вывода), поэтому она запускается первой. HMAC-проверка Зависит от процессора, но все равно быстро. Идемпотентность требует вызова Redis, поэтому она выполняется последней. Эта последовательность отвергает больше запросов по минимальной цене.

Проверьте свой обработчик веб-перехватчика с помощью почтового ящика веб-перехватчика botoi.

Используйте кнопки /v1/webhook/inbox чтобы создать одноразовый URL-адрес, вычислите подпись HMAC с помощью /v1/hash/hmacи отправьте подписанные тестовые полезные данные. Никаких туннелей, никакого локального сервера.

# 1. Create a webhook inbox
INBOX=\$(curl -s -X POST https://api.botoi.com/v1/webhook/inbox/create)
INBOX_ID=\$(echo \$INBOX | jq -r '.data.inbox_id')
INBOX_URL=\$(echo \$INBOX | jq -r '.data.url')
echo "Inbox URL: \$INBOX_URL"

# 2. Compute an HMAC signature for your test payload
PAYLOAD='{"id":"evt_001","event":"order.created","amount":2999}'
TIMESTAMP=\$(date +%s)
SIGNED_PAYLOAD="\$TIMESTAMP.\$PAYLOAD"

HMAC=\$(curl -s -X POST https://api.botoi.com/v1/hash/hmac \\
  -H "Content-Type: application/json" \\
  -d "{
    \\"text\\": \\"\$SIGNED_PAYLOAD\\",
    \\"key\\": \\"test_secret_key\\",
    \\"algorithm\\": \\"sha256\\"
  }" | jq -r '.data.hmac')
echo "HMAC: \$HMAC"

# 3. Send the signed payload to your inbox
curl -s -X POST "\$INBOX_URL" \\
  -H "Content-Type: application/json" \\
  -H "X-Webhook-Signature: \$HMAC" \\
  -H "X-Webhook-Timestamp: \$TIMESTAMP" \\
  -d "\$PAYLOAD"

# 4. Retrieve and inspect the payload
curl -s -X POST "https://api.botoi.com/v1/webhook/inbox/\$INBOX_ID/list" | jq '.data.payloads'

Конечная точка списка возвращает все полученные полезные данные:

[
  {
    "id": "p_abc123",
    "received_at": "2026-04-05T14:30:00Z",
    "body": {
      "id": "evt_001",
      "event": "order.created",
      "amount": 2999
    }
  }
]

Этот рабочий процесс позволяет протестировать весь процесс подписания из командной строки. Поменяйте URL-адрес папки «Входящие» на свой производственная конечная точка для запуска интеграционных тестов в CI.

Как Stripe, GitHub и Shopify подписывают вебхуки

Поставщик Заголовок Алгоритм Подписанный формат полезных данных Временная метка включена
Полоса Stripe-Signature HMAC-SHA256 t=UNIX.v1=HEX Да
GitHub X-Hub-Signature-256 HMAC-SHA256 sha256=HEX Нет
Shopify X-Shopify-Hmac-Sha256 HMAC-SHA256 В кодировке Base64 Нет
Твилио X-Twilio-Signature HMAC-SHA1 URL + отсортированные параметры Нет

Stripe — единственный, который объединяет метку времени с подписью, обеспечивая защиту от повторного воспроизведения сразу после установки. Для GitHub и Shopify добавьте собственный заголовок временной метки или проверьте время создания события из тела полезной нагрузки.

Контрольный список перед отправкой в ​​производство

  • Храните секрет веб-перехватчика в переменной среды, а не в исходном коде.
  • Анализируйте необработанное тело запроса, а не версию, проанализированную в формате JSON. HMAC подписывает точные байты в сети.
  • Использовать crypto.timingSafeEqual для сравнения подписей. Никогда не используйте ===.
  • Верните 200 перед запуском медленной обработки. Поставьте работу в очередь и подтвердите доставку.
  • Регистрируйте отклоненные запросы (неверная подпись, устаревшая отметка времени, дубликат) для отладки и оповещения.
  • Установите TTL в вашем хранилище идемпотентности. 72 часа охватывают большинство периодов повторных попыток поставщика услуг.
  • Перед подключением действующего провайдера протестируйте его с помощью одноразового почтового ящика и вычисленных подписей HMAC.

FAQ

Зачем использовать HMAC-SHA256 вместо общего секрета в параметре запроса?
Параметр запроса передается в виде открытого текста через прокси-серверы и журналы доступа. HMAC-SHA256 подписывает все тело запроса секретом, поэтому злоумышленник, перехвативший URL-адрес, все равно не сможет подделать действительную подпись.
Как долго мне следует хранить обработанные идентификаторы событий для обеспечения идемпотентности?
Храните их не менее 24–72 часов. Большинство поставщиков веб-перехватчиков повторяют попытку в этом окне. Через 72 часа вы можете безопасно удалить старые идентификаторы из своего магазина.
Какой допуск по временной метке следует использовать для защиты от повтора?
Пять минут (300 секунд) — стандарт. Stripe использует 300 секунд. Более короткие окна рискуют отклонить законные поставки, задержанные из-за перегрузки сети.
Могу ли я использовать конечную точку botoi hash/hmac для проверки входящих веб-перехватчиков?
Да. ОТПРАВЬТЕ тело полезной нагрузки и свой секрет в /v1/hash/hmac с помощью алгоритма sha256. Сравните возвращенный HMAC с заголовком подписи от поставщика веб-перехватчика.
Нужны ли мне все три защиты или можно выбрать одну?
Проверка HMAC является минимальной. Добавьте идемпотентность, если ваш обработчик веб-перехватчика вызывает побочные эффекты, такие как платежи или электронные письма. Добавьте защиту от повтора, если ваша система обрабатывает финансовые транзакции или события, важные для безопасности.

Начните разработку с botoi

150+ API-эндпоинтов для поиска, обработки текста, генерации изображений и утилит для разработчиков. Бесплатный тариф, без банковской карты.