12k
All articles

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

Проверяйте подписи вебхуков Stripe и GitHub в Express: исходное тело запроса, постоянное время сравнения HMAC, защита от повторов и смена секретов.

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

Чтобы проверить подпись вебхука, вычислите HMAC-SHA256-дайджест «сырого» тела запроса с помощью общего секрета. Затем сравните его за постоянное время с подписью, которую отправитель передал в заголовке запроса. Если значения не совпадают, отклоните запрос.

Ваш обработчик уже несколько недель работает в продакшене. Вы добавляете проверку подписи, и каждая доставка начинает падать с ошибкой «invalid signature», хотя секрет точно правильный.

Эндпоинт вебхука — это публичный URL, и отправить на него POST-запрос может кто угодно. Без проверки подписи обработчик доверяет всему, что к нему приходит. Если хотите сначала освежить в памяти сам паттерн, основы описаны в руководстве OpenReplay по вебхукам. В этой статье мы напишем корректный обработчик на Express для Stripe и GitHub. Мы разберём порядок подключения middleware для «сырого» тела, сравнение за постоянное время, защиту от replay-атак и проверку по двум секретам при ротации, а также объясним, зачем нужен каждый шаг.

Ключевые выводы

  • Подпись вебхука — это HMAC-SHA256-дайджест точной последовательности байтов запроса. Любой разбор JSON с повторной сериализацией до проверки приведёт к её провалу.
  • В Express регистрируйте маршрут вебхука с express.raw({ type: 'application/json' }) до любого глобального app.use(express.json()). Парсер тела, который запускается первым, поглощает поток запроса.
  • crypto.timingSafeEqual выбрасывает исключение, если два буфера различаются по длине. Поэтому сначала сравнивайте длины, а несовпадение считайте недействительной подписью.
  • Stripe подписывает строку ${t}.${rawBody} и может передавать по одной подписи v1 на каждый активный секрет. GitHub подписывает только «сырое» тело без метки времени, поэтому дубликаты отсекаются по идентификатору доставки.

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

Подпись вебхука — это HMAC-дайджест, который отправитель вычисляет по «сырому» телу запроса. Для этого он использует секрет, известный только вам и ему. Совпадение дайджестов доказывает две вещи: полезная нагрузка не была изменена, и её отправил тот, кто владеет секретом. HMAC примешивает секрет к хешу SHA-256, поэтому без секрета никто не сможет получить корректный дайджест для заданного тела. Ваш сервер повторяет вычисление и сравнивает результаты.

Оба рассматриваемых провайдера используют один и тот же примитив, но подписывают разные строки:

StripeGitHub
ЗаголовокStripe-Signature: t=<unix>,v1=<hex>X-Hub-Signature-256: sha256=<hex>
Подписываемая строка${t}.${rawBody}«сырое» тело
Кодировкаhexhex
Метка времени подписываетсяДаНет
Защита от replay-атакОтклонять устаревший tОтсекать дубликаты по X-GitHub-Delivery

Stripe описывает формат заголовка и подписываемую полезную нагрузку для ручной проверки. Тестовые события также содержат фиктивную подпись v0, поэтому игнорируйте все схемы, кроме v1. GitHub описывает свою схему в разделе о проверке доставок вебхуков: значение всегда начинается с sha256=. GitHub по-прежнему отправляет устаревший заголовок X-Hub-Signature с SHA-1, но сохраняет его лишь для совместимости со старыми интеграциями.

Считывайте «сырое» тело до любого JSON-парсера

Самая частая причина постоянных сбоев проверки подписи — JSON-парсер, который срабатывает раньше проверки. HMAC-проверка требует побайтового совпадения. Если вы повторно сериализуете разобранный объект, любое изменение пробелов, порядка ключей или экранирования приведёт к тому, что дайджест никогда не совпадёт.

Чаще всего ошибка кроется в порядке подключения middleware. В Express регистрируйте маршрут вебхука с express.raw() до любого глобального app.use(express.json()). Как только JSON-парсер поглотил поток запроса, исходные байты потеряны:

// Broken: the global JSON parser runs first, so req.body is a parsed object
app.use(express.json());
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), handler);

// Fixed: webhook routes first, global JSON parser after
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), handler);
app.use(express.json());

Добавление express.raw() к маршруту не исправляет сломанный вариант. В body-parser первый сработавший парсер считывает поток и помечает запрос как уже разобранный. Все последующие парсеры такой запрос пропускают. В результате raw-парсер на уровне маршрута ничего не получает, а в req.body остаётся объект, созданный express.json().

Добавьте в обработчик проверку Buffer.isBuffer(req.body). Тогда вместо вводящего в заблуждение «несовпадения подписи» вы сразу увидите ошибку конфигурации. Код в статье рассчитан на Express 5. В Express 5 req.body равен undefined, если тип содержимого не соответствует парсеру, а в Express 4 он был равен {}. Проверка в любом случае вернёт 400.

Если изменить порядок middleware нельзя, воспользуйтесь опцией verify в express.json(). Она передаёт вам «сырой» Buffer до разбора, и его можно сохранить в req для дальнейшего использования. В обработчике маршрута Next.js App Router никакой парсер тела не срабатывает до вашего кода. Поэтому считайте байты через Buffer.from(await request.arrayBuffer()), проверьте их и только после этого вызывайте JSON.parse.

Вычисляйте ожидаемую подпись и сравнивайте за постоянное время

Вычисляйте дайджест с помощью crypto.createHmac('sha256', secret) и передавайте ему Buffer напрямую. Декодирование тела в строку с последующим обратным кодированием добавляет лишний шаг, на котором байты могут измениться. Для Stripe используйте цепочку .update(`${t}.`).update(rawBody), чтобы тело попало в хеш в неизменном виде.

Подписи вебхуков нужно сравнивать за постоянное время, а не через ===. Сравнение строк может завершиться на первом несовпадающем символе, и время его выполнения выдаёт, какая часть догадки оказалась верной. crypto.timingSafeEqual(a, b) выполняется за одно и то же время независимо от того, где различаются буферы. Если длины буферов не совпадают, функция выбрасывает исключение. Поэтому сначала проверяйте длины: так некорректный заголовок будет считаться недействительной подписью, а не обрушит обработчик с ошибкой 500:

function safeEqualHex(expectedHex, receivedHex) {
  const a = Buffer.from(expectedHex, 'hex');
  const b = Buffer.from(receivedHex, 'hex');
  if (a.length === 0 || a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

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

  1. req.body является Buffer (выведите в лог Buffer.isBuffer(req.body)).
  2. Секрет относится именно к этому эндпоинту и режиму. У каждого эндпоинта Stripe свой секрет. Если эндпоинт используется и в тестовом, и в боевом режиме, для каждого режима у него отдельный секрет.
  3. Если GitHub вообще не присылает заголовок X-Hub-Signature-256, значит, для вебхука не настроен секрет. Убедитесь, что вы читаете заголовок SHA-256, а не устаревший SHA-1, как описано на странице GitHub по устранению неполадок.
  4. Подписываемая строка сформирована правильно: для Stripe нужен префикс t., а у GitHub префикс sha256= нужно отбросить.
  5. На обеих сторонах используется hex. Вставьте залогированное «сырое» тело и секрет в HMAC-генератор OpenReplay и вручную сравните полученный дайджест со значением из заголовка.

Отклоняйте повторы по метке времени или идентификатору доставки

Действительная подпись вебхука доказывает, кто отправил запрос, но не когда. Поэтому однажды перехваченный подписанный запрос можно воспроизвести позже. В случае Stripe отклоняйте любую доставку, подписанная метка времени которой старше примерно пяти минут. Это соответствует допуску по умолчанию в библиотеках Stripe. GitHub метку времени не подписывает, поэтому записывайте идентификатор каждой доставки и пропускайте дубликаты.

Проверяйте подпись раньше метки времени. Пока HMAC, покрывающий t, не проверен, t — просто текст, которому злоумышленник может присвоить любое значение. Нечисловое значение t тоже считайте ошибкой: сравнение с NaN даёт false, и наивная проверка свежести незаметно пропустит такой запрос.

Заголовок X-GitHub-Delivery содержит GUID каждого события, а при повторной доставке GUID сохраняется. Записывайте идентификатор только после успешной обработки. GitHub не повторяет неудавшиеся доставки автоматически и считает доставку неудавшейся, если не получил ответ 2xx в течение 10 секунд. Если записать идентификатор до сбоя, запущенная позже вручную повторная доставка будет пропущена.

Принимайте два секрета во время ротации

Во время ротации секрета вебхука принимайте запрос, если дайджест, вычисленный с любым действующим секретом, совпадает с любым значением v1 в заголовке. Когда вы выполняете ротацию секрета эндпоинта Stripe, старый секрет может оставаться действительным в течение выбранного вами периода — до 24 часов. Пока срок не истёк, каждая доставка содержит отдельную подпись v1 для каждого действующего секрета.

Парсер заголовка Stripe, который сворачивает заголовок в объект, сохранит только одно значение v1 и начнёт отклонять корректные доставки. Поэтому собирайте все значения в массив. Для GitHub проверяйте единственный заголовок по очереди с каждым настроенным секретом. Циклы some() завершаются досрочно при совпадении, но это не раскрывает ничего полезного: каждое отдельное сравнение по-прежнему выполняется за постоянное время.

Полный обработчик на Express

Этот ESM-обработчик объединяет все описанные шаги. При любой ошибке подписи или формата он возвращает 400 (отправители различают только 2xx и не-2xx), а для дубликатов GitHub возвращает 200: такие доставки подтверждаются и пропускаются:

import express from 'express';
import crypto from 'node:crypto';

const app = express();
const TOLERANCE_SECONDS = 300;

// Current secret first, previous secret during rotation
const STRIPE_SECRETS = [
  process.env.STRIPE_WEBHOOK_SECRET,
  process.env.STRIPE_WEBHOOK_SECRET_PREVIOUS,
].filter(Boolean);
const GITHUB_SECRETS = [
  process.env.GITHUB_WEBHOOK_SECRET,
  process.env.GITHUB_WEBHOOK_SECRET_PREVIOUS,
].filter(Boolean);

if (STRIPE_SECRETS.length === 0 || GITHUB_SECRETS.length === 0) {
  throw new Error('Webhook secrets are not configured');
}

function safeEqualHex(expectedHex, receivedHex) {
  const a = Buffer.from(expectedHex, 'hex');
  const b = Buffer.from(receivedHex, 'hex');
  if (a.length === 0 || a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

function parseStripeHeader(header) {
  let timestamp = null;
  const signatures = [];
  for (const part of header.split(',')) {
    const i = part.indexOf('=');
    if (i === -1) continue;
    const key = part.slice(0, i).trim();
    const value = part.slice(i + 1).trim();
    if (key === 't') timestamp = value;
    else if (key === 'v1') signatures.push(value); // keep every v1
  }
  return { timestamp, signatures };
}

app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('Stripe-Signature');
  if (!header || !Buffer.isBuffer(req.body)) {
    return res.status(400).send('Missing signature or raw body');
  }

  const { timestamp, signatures } = parseStripeHeader(header);
  if (!timestamp || signatures.length === 0) {
    return res.status(400).send('Malformed signature header');
  }

  const valid = STRIPE_SECRETS.some((secret) => {
    const expected = crypto
      .createHmac('sha256', secret)
      .update(`${timestamp}.`)
      .update(req.body)
      .digest('hex');
    return signatures.some((sig) => safeEqualHex(expected, sig));
  });
  if (!valid) return res.status(400).send('Invalid signature');

  // Only trust t after the HMAC over it has been verified
  const age = Math.floor(Date.now() / 1000) - Number(timestamp);
  if (!Number.isFinite(age) || Math.abs(age) > TOLERANCE_SECONDS) {
    return res.status(400).send('Timestamp outside tolerance');
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Hand event off to a queue; respond fast
  res.sendStatus(200);
});

const seenDeliveries = new Set(); // Use a shared store with a TTL in production

app.post('/webhooks/github', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('X-Hub-Signature-256');
  if (!header?.startsWith('sha256=') || !Buffer.isBuffer(req.body)) {
    return res.status(400).send('Missing signature or raw body');
  }
  const received = header.slice('sha256='.length);

  const valid = GITHUB_SECRETS.some((secret) =>
    safeEqualHex(
      crypto.createHmac('sha256', secret).update(req.body).digest('hex'),
      received,
    ),
  );
  if (!valid) return res.status(400).send('Invalid signature');

  const deliveryId = req.get('X-GitHub-Delivery');
  if (!deliveryId) return res.status(400).send('Missing delivery ID');
  if (seenDeliveries.has(deliveryId)) return res.sendStatus(200);

  const payload = JSON.parse(req.body.toString('utf8'));
  // Process payload, then record the ID only after success
  seenDeliveries.add(deliveryId);
  res.sendStatus(200);
});

app.use(express.json()); // Everything else, after the webhook routes

app.listen(3000);

Каждая проверка здесь отвечает за конкретный сценарий сбоя. Отсутствующий заголовок приводит к ответу 400, а не к исключению. Сохраняются все значения v1. Длины сравниваются до вызова timingSafeEqual. Тело, не являющееся Buffer, распознаётся как ошибка конфигурации. Если приложение работает только со Stripe, те же проверки за вас выполнит stripe.webhooks.constructEvent из официальной библиотеки. Как её вызывать, показано в документации Stripe по вебхукам.

Заключение

Проверка подписи вебхука сводится к тому, чтобы вычислить хеш от тех самых байтов, которые пришли, и безопасно сравнить результат. Остальная часть обработчика нужна, чтобы сохранить эти байты нетронутыми, предотвратить повторное воспроизведение запросов и пережить ротацию секретов. Сначала перенесите маршруты вебхуков выше express.json(), а затем добавьте приведённый выше обработчик. Если вы ещё решаете, подходит ли push-доставка для вашей интеграции, сравнение компромиссов приведено в статье о вебхуках и поллинге.

Часто задаваемые вопросы

Почему проверка подписи Stripe не проходит при локальном тестировании через Stripe CLI?

Stripe CLI использует собственный секрет подписи, который отличается от секрета любого эндпоинта, созданного в Stripe Dashboard. Оба начинаются с whsec_, поэтому их легко перепутать. При запуске stripe listen CLI выводит свой секрет в терминал. Используйте это значение для событий, которые пересылает CLI, и никогда не проверяйте такие события секретом эндпоинта из Dashboard — и наоборот.

Что лучше: использовать constructEvent из библиотеки Stripe или проверять подпись вручную?

Если Stripe — ваш единственный провайдер, используйте stripe.webhooks.constructEvent. Вы передаёте ей «сырое» тело запроса, значение заголовка Stripe-Signature и секрет эндпоинта, а если подпись не проходит проверку, функция выбрасывает ошибку. По умолчанию библиотеки Stripe отклоняют подписанную метку времени, которая отличается от текущего времени более чем на 5 минут. Задать другое окно можно через необязательный аргумент. Ручная проверка оправдана, когда один и тот же код обрабатывает вебхуки GitHub или других провайдеров.

Зачем использовать HMAC, а не просто хешировать секрет вместе с телом через SHA-256?

HMAC устойчив к атакам удлинения сообщения (length extension), которые ломают наивную конструкцию вроде SHA-256 от секрета, за которым следует тело. SHA-256 построен по схеме Меркла — Дамгора. Поэтому любой, у кого есть корректный дайджест, может дописать данные к сообщению и вычислить корректный дайджест для удлинённого сообщения, не зная секрета. HMAC хеширует ключ в два вложенных прохода, что блокирует такую атаку. И Stripe, и GitHub подписывают вебхуки с помощью HMAC-SHA256.

Нужен ли HTTPS, если я проверяю подписи вебхуков?

Да. HMAC-подпись защищает целостность и подлинность, но не конфиденциальность. Без TLS любой участник на сетевом пути может прочитать полезную нагрузку, которая может содержать данные клиентов или платёжные данные, а также перехватить подписанный запрос, чтобы позже воспроизвести его. Проверка подписи, защита от повторов и HTTPS закрывают разные угрозы, поэтому эндпоинту вебхука в продакшене нужны все три.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.