Ошибка проверки подписи webhook? Найдите свою причину
Ошибка webhook signature verification failed означает ровно одно: digest, который вычислил ваш код, не равен digest из header запроса. Больше в ней ничего не сказано: ни про права доступа, ни про истечение срока. И это почти никогда не баг в SDK провайдера. Что-то различается между байтами, от которых считал хеш провайдер, и байтами, от которых считали хеш вы. Само слово в русскоязычных обсуждениях и поисковых запросах обычно пишут по-русски, «вебхук», хотя в коде и в названиях header оно остается латиницей.
Результат определяют четыре входа: какие байты были подписаны, какие байты ключа использованы, какая хеш-функция отработала и в какой текстовой кодировке шло сравнение. Ошибитесь в любом из них, и сбой будет выглядеть одинаково. Само сообщение не подсказывает, в каком именно, так что вчитываться в него бесполезно: сужать надо пространство входных данных.
Ветку выбирайте по симптому:
Подпись не совпадает? Три ветки:
├─ Фреймворк разобрал JSON до того, как вы его увидели? → раздел 3
├─ В значении header есть префикс или это похоже на base64? → раздел 4
└─ В header провайдера есть timestamp? → раздел 2
1. О чём говорит несовпадение подписи
Проверка — это сравнение двух последовательностей байтов. Когда она падает, неверно ровно одно из четырех, и эти четыре вещи друг от друга не зависят.
Какие байты были подписаны. Провайдер вычислил хеш от конкретной последовательности байтов. Может быть, это только тело запроса, а может быть и timestamp, приклеенный к телу спереди. Если фреймворк разобрал JSON и отдал вам объект, этих байтов у вас больше нет и надежно восстановить их нельзя. Об этом раздел 3, и это причина номер один с большим отрывом.
Какие байты ключа использованы. Одну и ту же строку секрета можно прочитать как текст в UTF-8, как hex или как base64, и каждое чтение даст другой ключ. Другой ключ получится и из секрета, в котором загрузчик конфигурации сохранил лишний перевод строки. В этом измерении спрятан второй сбой: секрет может быть вообще не тем секретом, а не неверным чтением верного. Об этом раздел 6.
В какой кодировке шло сравнение. Для SHA-256 digest — это 32 сырых байта. Hex и base64 — два способа записать те же самые байты текстом, и друг на друга они не похожи никогда. Сравните одно с другим, и получите вечное несовпадение подписи HMAC (hmac signature mismatch), хотя байты под ними совпадают.
Какая хеш-функция отработала. Большинство провайдеров используют SHA-256 и пишут об этом в документации, так что это измерение обычно ничего не стоит. Исключение, о котором стоит знать, это GitHub: в каждой доставке рядом с X-Hub-Signature-256 (HMAC-SHA256) едет X-Hub-Signature (HMAC-SHA1), и документация самого GitHub про header с SHA-1 говорит, что он «включен исключительно для совместимости со старыми интеграциями», и рекомендует вариант 256. Если прочитать не тот, раньше байтов это выдаст длина. Тело из раздела 2, подписанное тем же секретом по SHA-1, дает sha1=ba2954d180839d8170b08b32cd38483775aaae96: 40 символов hex против 64 у его digest SHA-256.
Держите эти четыре измерения раздельно, пока ищете причину. Быстрее всего изолировать измерение так: вычислить digest вне приложения из входов, которыми управляете вы. Вставьте тело и секрет в генератор HMAC и посмотрите, что получится. Он работает целиком в браузере, и секрет не покидает страницу, так что боевой signing secret вставлять в него безопасно. HMAC использует ту же примитивную функцию SHA-256, что и обычный хеш SHA-256, только с вашим секретом в качестве ключа. Значит, если значение провайдера удалось воспроизвести руками, криптография в порядке, а баг — в обработке запроса.
2. Что подписывают четыре крупных провайдера
Большинство интеграций ломается на одном предположении: будто провайдер подписывает тело запроса и ничего больше. Двое из четырех крупнейших так не делают. Вот что каждый из них пропускает через хеш:
| Провайдер | Header | Подписываемая строка | Кодирование | Префикс значения | Секрет | Допуск по timestamp |
|---|---|---|---|---|---|---|
| Stripe | Stripe-Signature | {timestamp} + . + rawBody | hex | t=…,v1=…,v0=… | signing secret для endpoint (префикс whsec_) | 5 минут (300 секунд) |
| GitHub | X-Hub-Signature-256 | rawBody (без префикса) | hex | sha256= | секретный token для webhook | нет (timestamp не передается) |
| Slack | X-Slack-Signature + X-Slack-Request-Timestamp | v0: + {timestamp} + : + rawBody | hex | v0= | signing secret | 5 минут |
| Shopify | X-Shopify-Hmac-SHA256 | rawBody | base64 | нет | client secret приложения (не отдельный секрет для webhook) | нет |
Эти четыре случая как раз покрывают три независимые оси. Подписываемая строка — либо тело само по себе, либо конкатенация с timestamp, причем даже разделитель разный: у Stripe это ., у Slack — :. Кодирование — hex у трех и base64 у одного. У трех провайдеров секрет берется из выделенного ключа для webhook, а у Shopify — из client secret приложения; на этой детали спотыкаются чаще всего, потому что в админке есть поле с меткой «webhook», и это не то, что нужно.
Вот одно тело, подписанное четырьмя способами одним и тем же секретом:
body : {"id":42,"event":"user.created"}
secret : whsec_test_secret
ts : 1700000000
| Формат | Значение |
|---|---|
| В стиле GitHub | sha256=09dd9fef34ca68915e1ba93eb7515cbc33e7e753806767f81abc6409480c846b |
| В стиле Shopify | Cd2f7zTKaJFeG6k+t1FcvDPn51OAZ2f4GrxkCUgMhGs= |
| В стиле Stripe | t=1700000000,v1=4b56de5a58122bab8ebbadbed663fbc17d810096d57498f5b24a72f5123b2375 |
| В стиле Slack | v0=3faf37337484c62dcd1a6c1ff308d1345c31291e4aac9b44554a99e8e35a1f9c |
Первые две строки читайте вместе: это один и тот же 32-байтовый digest, записанный дважды. В hex это шестьдесят четыре символа, в base64 — сорок четыре вместе с padding. Ничто в этих двух строках не намекает, что они равны, и именно поэтому сравнение через разные кодировки дает несовпадение, которое переживает любую проверку в духе «но ведь секрет правильный».
Последние две строки показывают вторую половину той же мысли. Тело то же, секрет тот же, алгоритм тот же, но ни один из этих digest не похож на вариант GitHub, потому что строка, от которой считается хеш, теперь начинается с timestamp. Большинство сообщений об ошибке stripe webhook signature verification failed сводится именно к этой строке: код посчитал хеш от одного тела и никогда не подставил впереди значение t и точку. Все четыре значения воспроизводятся в генераторе HMAC, если менять только поле сообщения и формат вывода.
Одно практическое следствие из колонки с timestamp: digest у Stripe и Slack действителен всего несколько минут, поэтому нельзя снять подпись сегодня и переиграть ее в тесте завтра. Подписи GitHub и Shopify стабильны навсегда — их гораздо проще отлаживать, но и о защите от повторной отправки придется думать самому.
3. Проблема сырого тела запроса
Большинство сообщений об ошибке проверки подписи webhook приводит именно сюда.
Фреймворк уже уничтожил байты
Веб-фреймворки существуют, чтобы избавить вас от разбора запроса. Именно это удобство и ломает проверку подписи: к моменту, когда запускается ваш обработчик, исходных байтов уже нет.
express.json() читает поток запроса, разбирает его и подменяет req.body объектом JavaScript. Поток вычитан, и прочитать его снова нельзя. В FastAPI объявление модели Pydantic или параметра тела типа dict означает, что фреймворк читает и разбирает данные до входа в вашу функцию. Rails заполняет params из тела JSON через middleware, который срабатывает раньше действия контроллера. Конвертер Jackson в Spring превращает тело в ваш класс DTO, и по умолчанию нижележащий поток HttpServletRequest можно прочитать только один раз.
Ничего из этого не баг. Каждый из компонентов делает именно то, на что его настроили. Проблема в том, что подпись покрывает байты, объект байтами не является, а превращение объекта обратно в байты — операция не та, которую выполнил провайдер.
Почему повторная сериализация иногда работает
Обычный совет звучит так: повторная сериализация меняет байты. Это половина правды, и как раз недостающая половина делает сбой таким трудным для диагностики. Иногда она не меняет вообще ничего.
Вот JSON.stringify(JSON.parse(body)) === body, замеренный на разных формах payload:
| Форма payload | Байты после round-trip | Изменение |
|---|---|---|
{"id":42,"event":"user.created"} | идентичны | нет — поэтому локальные тесты и проходят |
{"amount":1.0} | изменились | → {"amount":1} |
{"n":1e3} | изменились | → {"n":1000} |
{"id":12345678901234567890} | изменились | → {"id":12345678901234567000} (потеря точности) |
{"name":"caf\u00e9"} | изменились | → {"name":"café"} (6 байтов превращаются в 2) |
{"a":1}\n | изменились | завершающий перевод строки проглочен |
{ "a" : 1 } | изменились | внутренние пробелы проглочены |
{"v":-0.0} | изменились | → {"v":0} |
{"p":0.1000000000000000055511151231257827} | изменились | → {"p":0.1} |
Посмотрите на первую строку. Плоский объект с целым числом и короткой ASCII-строкой проходит round-trip байт в байт, поэтому верификатор, который разбирает и снова сериализует, проходит все тесты, написанные на такой фикстуре. Потом вы деплоите, и первый же payload с денежной суммой 1.0, с ID больше 2^53 или с именем клиента, в котором есть диакритика, падает. Падают именно такие, остальные проходят.
Отсюда и растет то самое «локально работает, в проде периодические 401». Такой верификатор хуже того, который падает всегда: падающий всегда починят за час, а падающий на части событий спишут на провайдера, обвесят retry и будут жить с этим неделями. Если у вас доля отказов строго между нулем и сотней процентов, начинать надо с этой таблицы.
Порядок ключей — та причина, которую ожидают все, и на практике самая маловероятная: JSON.parse сохраняет порядок вставки для строковых ключей. Настоящие виновники — числа и пробелы.
Как получить сырое тело в каждом фреймворке
Express — маршрутный парсер регистрируется раньше глобального парсера JSON:
const express = require('express');
const crypto = require('crypto');
const app = express();
// Этот маршрут нужно зарегистрировать РАНЬШЕ, чем app.use(express.json()).
// body-parser помечает запрос как разобранный, поэтому более поздний raw() молча вернет {}.
app.post('/webhooks/github', express.raw({ type: 'application/json' }), (req, res) => {
const raw = req.body; // Buffer, а не объект
const digest = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(raw) // считаем хеш прямо от Buffer, без toString()
.digest('hex');
console.log('bytes:', raw.length, 'digest:', digest);
res.sendStatus(200);
});
app.use(express.json()); // все остальные маршруты по-прежнему получают разобранный JSON
app.listen(3000);
Если переупорядочить middleware нельзя, сохраняйте копию во время разбора:
app.use(express.json({
verify: (req, res, buf) => { req.rawBody = Buffer.from(buf); },
}));
FastAPI. Starlette кеширует тело, поэтому await request.body() возвращает исходные байты даже в обработчике, который заодно получает разобранную модель:
import hashlib, hmac, os
from fastapi import FastAPI, HTTPException, Request
app = FastAPI()
@app.post("/webhooks/github")
async def github(request: Request):
raw = await request.body() # байты, ровно как пришли
expected = "sha256=" + hmac.new(
os.environ["WEBHOOK_SECRET"].encode("utf-8"), raw, hashlib.sha256
).hexdigest()
received = request.headers.get("X-Hub-Signature-256", "")
if not hmac.compare_digest(expected, received):
raise HTTPException(status_code=401, detail="bad signature")
return {"ok": True}
Rails, где request.raw_post отдает неразобранное тело строкой:
class WebhooksController < ApplicationController
skip_before_action :verify_authenticity_token
def shopify
raw = request.raw_post
digest = Base64.strict_encode64(
OpenSSL::HMAC.digest('sha256', ENV['SHOPIFY_CLIENT_SECRET'], raw)
)
unless OpenSSL.secure_compare(digest, request.headers['X-Shopify-Hmac-SHA256'].to_s)
return head :unauthorized
end
head :ok
end
end
Go, где тело вы читаете сами и обязаны помнить, что после этого оно опустошено:
func handler(w http.ResponseWriter, r *http.Request) {
raw, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "unreadable body", http.StatusBadRequest)
return
}
mac := hmac.New(sha256.New, []byte(os.Getenv("WEBHOOK_SECRET")))
mac.Write(raw)
expected := mac.Sum(nil)
got, err := hex.DecodeString(
strings.TrimPrefix(r.Header.Get("X-Hub-Signature-256"), "sha256="))
if err != nil || !hmac.Equal(expected, got) {
http.Error(w, "bad signature", http.StatusUnauthorized)
return
}
// Unmarshal делайте из raw, никогда из r.Body — байтов там больше нет.
w.WriteHeader(http.StatusOK)
}
Spring, где запрос byte[] полностью обходит Jackson:
@PostMapping(path = "/webhooks/github", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Void> github(@RequestBody byte[] payload,
@RequestHeader("X-Hub-Signature-256") String header)
throws GeneralSecurityException {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String expected = "sha256=" + HexFormat.of().formatHex(mac.doFinal(payload));
boolean ok = MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8),
header.getBytes(StandardCharsets.UTF_8));
return ok ? ResponseEntity.ok().build()
: ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
ContentCachingRequestWrapper — альтернатива, когда проверка обязана жить в фильтре, а сигнатуру контроллера менять нельзя. У него своя ловушка: getContentAsByteArray() возвращает байты только после того, как поток прочитал кто-то ниже по цепочке, поэтому вызов до chain.doFilter(...) даст пустой массив.
4. Несовпадения кодировок: hex, base64 и сам ключ
Между вашим digest и значением header стоят три независимых решения о кодировании, и любое из них само по себе ломает сравнение.
Кодирование digest. На выходе HMAC-SHA256 — 32 байта. Записанные строчным hex, это 64 символа; записанные стандартным base64 — 44 вместе с = в padding. Вот те же две строки из раздела 2, поставленные рядом:
| Кодирование | Символов | Те же 32 байта, записанные как |
|---|---|---|
| hex | 64 | 09dd9fef34ca68915e1ba93eb7515cbc33e7e753806767f81abc6409480c846b |
| base64 | 44 | Cd2f7zTKaJFeG6k+t1FcvDPn51OAZ2f4GrxkCUgMhGs= |
Быстрая эвристика, когда вы смотрите на незнакомый header: если значение — 64 символа из 0-9a-f, это hex. Если это 44 символа с = на конце либо в нем встречаются +, / или заглавные буквы, это base64. Чтобы не гадать, прогоните значение base64 через декодер Base64 и проверьте, что получилось 32 байта. Получилось — значит, обе строки описывают один digest, а сравнивали вы текстовые форматы, а не подписи.
Префикс значения. GitHub ставит перед hex sha256=. Slack — v0=. Stripe заворачивает всё в список пар key=value через запятую. Ни один из этих символов не входит в digest, поэтому либо срежьте префикс из header, либо добавьте его к своему значению. Не сделать ни того, ни другого — самая частая причина, по которой корректная реализация сообщает о несовпадении подписи HMAC (hmac signature mismatch); а в Node она даже не сообщает о несовпадении, как объясняет раздел 7.
Кодирование ключа. Секрет — тоже байты, и одна и та же строка, прочитанная как UTF-8, hex или base64, дает три разных ключа. Провайдеры, которые выдают текстовый token вида whsec_..., ждут UTF-8, но масса внутренних систем раздает секреты в base64 или hex, и их нужно декодировать перед подписыванием. По форме сбой полностью совпадает с версией той же проблемы для JWT и подробно разобран в статье JWT invalid signature: все причины и способы исправления — в том числе как понять, base64 перед вами или обычный текст.
5. Timestamp, допуск и окно повторной отправки
Можно вычислить идеально совпадающий digest и всё равно получить отказ. Провайдеры, которые передают timestamp, ожидают, что вы его проверите, а устаревший timestamp — это корректная подпись, которую всё равно нужно отклонить.
| Провайдер | Где живет timestamp | Окно |
|---|---|---|
| Stripe | t= внутри Stripe-Signature | 5 минут (300 секунд) |
| Slack | header X-Slack-Request-Timestamp | 5 минут |
| GitHub | не передается | не применимо |
| Shopify | не передается | не применимо |
Ошибиться с окном больно в обе стороны. Слишком щедрое оставляет захваченный запрос пригодным для повтора столько, сколько вы разрешили, а это сводит на нет почти весь смысл проверки timestamp. Слишком тугое начинает отклонять настоящие доставки при обычном дрейфе часов. Пять минут выбрали оба провайдера, и скопировать это решение — здравый вариант по умолчанию.
Прежде чем расширять допуск, проверьте часы. В образах контейнеров NTP не работает, а виртуальная машина, поднятая из снапшота, может отставать от реального времени на минуты, и в логах об этом не будет ни строчки. Если хост дрейфует равномерно, сбои сначала редкие, потом их сто процентов; выглядит это как регрессия в коде, хотя код не менялся.
Второй баг с часами — несовпадение единиц. Все провайдеры из таблицы присылают epoch в секундах. Сравните это со значением в миллисекундах вроде Date.now() в JavaScript, и разница окажется примерно в тысячу раз больше реального возраста, поэтому каждое событие вылетит за любое разумное окно. Симптом: проверка допуска отклоняет сто процентов доставок, при том что сам digest совпадает. Если вы не уверены, какая единица у вас в руках, подсказка — длина; про переводы и связанные с ними ловушки часовых поясов есть статья epoch в секундах против миллисекунд.
Собирая подписываемую строку, берите строку timestamp из header как есть, а не разобранное и переформатированное число. Разбор 1700000000 во float и печать обратно могут дать 1700000000.0, а это уже другая последовательность байтов.
6. Не тот секрет и секреты, которые ротируются
Прежде чем уходить глубже в кодировки, исключите самую простую причину: секрет может быть не тем. Документация Stripe прямо говорит, что «Stripe генерирует уникальный секретный ключ для каждого endpoint», и что если направить один и тот же URL на тестовый и на боевой ключ, то «секрет у каждого из них свой». Отсюда растут три версии одной ошибки.
Тестовый и боевой режим держат отдельные секреты, поэтому значение, скопированное при панели в тестовом режиме, роняет каждую боевую доставку. Свой секрет есть и у каждого endpoint: документация добавляет, что «если вы используете несколько endpoint, нужно получить секрет для каждого, на котором собираетесь проверять подписи». Направьте два endpoint в один обработчик с одним секретом в окружении, и половина трафика перестанет проходить. А stripe listen печатает signing secret для локального проброса из CLI, и это отдельный endpoint от всего, что зарегистрировано в панели, так что менять их местами нельзя.
Ни одна из этих ситуаций снаружи не похожа на баг кодирования. Digest построен правильно, сравнение верное, а значение в вашем окружении — настоящий секрет Stripe, просто не тот, которым подписана эта доставка.
Ротация — то же измерение, только оно уезжает у вас под ногами. Она меньше всего похожа на проблему с кодировкой и чаще всего получает диагноз «баг в коде». В коде ничего не менялось, вчера проверка работала, а теперь часть событий падает.
Окно перекрытия сделано намеренно. Stripe держит старый секрет endpoint действительным до 24 часов после ротации, и в этот период header Stripe-Signature несет по одной подписи v1 на каждый активный секрет. Shopify устроен наоборот: после ротации может пройти до часа, прежде чем он начнет считать digest новым секретом, так что пока нужен старый.
Ломает код именно поведение Stripe, потому что header выглядит так, будто подпись в нем одна. Разбить по , и взять первую найденную v1 работает ровно до момента, когда их становится две; дальше вы попадаете примерно в половине случаев — в зависимости от того, какой секрет подписал какое событие. Перебирайте все:
const crypto = require('crypto');
function verifyStripe(header, rawBody, secret, toleranceSec = 300) {
let t = null;
const v1 = [];
for (const pair of header.split(',')) {
const idx = pair.indexOf('=');
const key = pair.slice(0, idx);
const value = pair.slice(idx + 1);
if (key === 'v1') v1.push(value);
else if (key === 't') t = value; // сохраняем исходную строку
}
if (t === null || v1.length === 0) return false;
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(t));
if (!Number.isFinite(age) || age > toleranceSec) return false;
const signedPayload = Buffer.concat([Buffer.from(`${t}.`, 'utf8'), rawBody]);
const expected = crypto.createHmac('sha256', secret).update(signedPayload).digest();
return v1.some((sig) => {
const received = Buffer.from(sig, 'hex');
return received.length === expected.length &&
crypto.timingSafeEqual(received, expected);
});
}
Помимо самого цикла там важны две детали. Timestamp попадает в подписываемый payload той строкой, в которой пришел, а тело присоединяется как байты, а не через интерполяцию шаблона — та сначала декодировала бы его как UTF-8.
Та же схема работает, когда ротацию делаете вы: принимайте и старый, и новый секрет на всю длину перекрытия, а потом выбрасывайте старый. То, на что вы переходите, должно иметь полную энтропию, поэтому его нужно генерировать, а не набирать руками — например, в генераторе signing secret для 256-битного случайного значения.
7. Сравнение подписей без утечки по времени
Когда у вас на руках два digest, способ их сравнения — решение из области безопасности. Обычное сравнение строк возвращает результат, как только находит различающийся байт, поэтому затраченное время выдает, сколько ведущих байтов оказались верными. Атакующий, который может отправить много запросов, восстанавливает по этому каналу корректную подпись байт за байтом. Через интернет это медленно и шумно, а в локальной сети — вполне практично.
В каждой среде исполнения есть сравнение за фиксированное время:
| Язык | Сравнение за фиксированное время | Когда длины различаются |
|---|---|---|
| Node | crypto.timingSafeEqual(a, b) | бросает исключение |
| Python | hmac.compare_digest(a, b) | возвращает False |
| Go | hmac.Equal(a, b) | возвращает false |
| PHP | hash_equals($known, $user) | возвращает false |
| Ruby | OpenSSL.secure_compare(a, b) | возвращает false |
Из последней колонки растет целый класс путаных инцидентов. Node здесь выбивается из ряда, и падает он невежливо:
RangeError [ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH]: Input buffers must have the same byte length
Hex-запись digest SHA-256 — 64 символа. Значение в X-Hub-Signature-256 — 71, потому что sha256= занимает семь символов. Забудьте срезать префикс, и у двух буферов разная длина, поэтому timingSafeEqual бросает исключение вместо того, чтобы вернуть false. Непойманным это исключение вылетает из обработчика, и Express превращает его в 500.
Вы ищете ответ webhook 401 unauthorized, а получаете ошибку сервера, и идете читать обработчик, вызов базы, диспетчер событий. А баг сидит на строку выше сравнения. Сравнение 64-символьного hex-digest с 44-символьным base64 бросает исключение по той же причине: значит, несовпадение кодировок в Node тоже проявляется как 500, а не как чистый отказ.
Проверяйте длину сами и возвращайте false:
function safeEqualHex(receivedHex, expectedHex) {
const a = Buffer.from(receivedHex, 'hex');
const b = Buffer.from(expectedHex, 'hex');
if (a.length !== b.length) return false; // защита перед вызовом
return crypto.timingSafeEqual(a, b);
}
Утечка длины безвредна: длина digest фиксирована алгоритмом и общеизвестна. Утекать не должно то, какой именно префикс совпал. Вкладка «Проверить» в генераторе HMAC складывает разницу длин в тот же накопитель, работающий за фиксированное время, и не выходит из цикла раньше времени, поэтому несовпадение длины возвращается обычным false, а не исключением, и значение header можно сверить с вычисленным digest, не написав ни строчки одноразового кода.
8. Когда байты изменил транспортный уровень
Вы исключили подписываемую строку, сырое тело, кодировки, часы и ротацию. Остается вариант, что байты, доехавшие до вашего процесса, — не те байты, которые вышли от провайдера.
Сжатие. Провайдер или прокси может отправить тело в gzip с Content-Encoding: gzip. Подпись покрывает несжатый payload, поэтому хеш нужно считать после распаковки. Одни фреймворки распаковывают прозрачно, другие отдают сжатые байты, а выдает это тело, которое в логе выглядит как бинарный мусор.
Передача по частям (chunked). При Transfer-Encoding: chunked нет Content-Length, и код, который доверяет этому header при выборе размера буфера чтения, обрезает тело. Digest обрезанного тела — валидная бессмыслица: он не совпадет никогда, и при этом ничего подозрительного не видно.
Прокси и WAF. Любой слой, который читает и перезаписывает тело, может его изменить. AWS API Gateway умеет закодировать тело в base64 прежде, чем оно доедет до Lambda, так что декодировать нужно до вычисления хеша. Правит тело не только API Gateway: балансировщики уровня приложения, service mesh и межсетевые экраны уровня приложений тоже умеют нормализовать или перекодировать payload. Чтобы это поймать, сравните длину в байтах, которую видит ваш обработчик, с Content-Length, присланным провайдером.
Кодировка символов и BOM. В payload могут встречаться не-ASCII символы, и документация GitHub прямо говорит, что payload нужно обрабатывать как UTF-8. Декодирование тела в строку в неправильной кодировке и повторное кодирование уничтожает каждый многобайтовый символ. Метка порядка байтов UTF-8, EF BB BF, добавленная из лучших побуждений редактором или сериализатором, дает три байта, которых в подписи не было.
Переводы строк и случайные пробелы. Тело, прошедшее через границу файла в текстовом режиме, может приехать с LF, переписанным в CRLF. Прочитайте в спецификации провайдера и точную подписываемую строку: некоторые дописывают собственный символ, а Typeform — документированный случай, когда завершающий перевод строки входит в то, от чего считается хеш. Если в документации провайдера упомянут любой дополнительный символ, понимайте это буквально.
9. Воспроизводимый порядок отладки
Выполняйте по порядку. Каждый шаг либо находит баг, либо отсекает ветку, и остановиться раньше — это и есть цель.
- Залогируйте сырые байты до запуска любого middleware. Запишите тело в файл или выведите в лог его длину в байтах плюс SHA-256 — из самой ранней точки жизненного цикла запроса, до которой можете дотянуться. Одна длина закрывает удивительно много случаев: значение на единицу больше ожидаемого — завершающий перевод строки, на три больше — BOM.
- Вычислите digest руками. Вставьте ровно эти байты и свой секрет в генератор HMAC, выберите SHA-256 и задайте формат вывода под header. Этот шаг разрезает задачу ровно на две половины.
- Сравните посчитанное руками значение с header. Равно — значит, и байты, и секрет верны, а баг где-то в вашем пути исполнения, идите читать сравнение. Не равно — значит, один из входов неверный, продолжайте.
- Сверьте подписываемую строку с таблицей из раздела 2. Этот провайдер ставит timestamp впереди? С каким разделителем? Добавьте префикс в инструменте и пересчитайте.
- Переключите кодирование digest. Пересчитайте в hex и в base64 и сравните оба варианта с header. Значение header из 44 символов с
=на конце — это base64, что бы ни предполагал ваш код. - Переключите кодирование ключа. Попробуйте секрет как текст, потом как hex, потом как base64. Один из трех обычно дает совпадение, и это говорит вам, чего ждет провайдер.
- Проверьте часы и состояние ротации. Сравните время сервера с известным источником, убедитесь, что имеете дело с epoch в секундах, и посмотрите в панели провайдера, не было ли ротации за последние 24 часа.
Две привычки сильно ускоряют этот цикл. Первая: снимите один падающий payload и работайте с ним офлайн, а не ждите следующей доставки. Вторая: переигрывайте это снятое тело на своем endpoint с фиксированной подписью, чтобы вход не менялся между попытками. Генератор команд cURL собирает запрос с нужными header и телом, прочитанным из файла, так что байты остаются стабильными между запусками. Один проход по списку выше закрывает воспроизводимый сбой; периодический сначала придется сделать воспроизводимым.
Если тикет в поддержку всё же нужен, приложите длину в байтах того тела, от которого считали хеш, значение header дословно, конструкцию подписываемой строки и кодирование digest. Сам секрет не прикладывайте никогда.
FAQ
Почему подпись webhook работает локально, но падает в проде?
Ваш тестовый payload, скорее всего, переживает round-trip через JSON без изменений, поэтому повторная сериализация ему безвредна. В реальных payload есть числа с плавающей точкой, большие целые, escape-последовательности Unicode или лишние пробелы, и они байты меняют. Подписывайте сырое тело, а не пересериализованную копию; таблица в разделе 3 показывает, какие формы ломаются.
Нужно ли учитывать префикс sha256= при сравнении подписей?
Срежьте его или добавьте к своему значению, чтобы обе строки совпадали точно. Ваш вычисленный hex-digest — 64 символа, а значение header с префиксом — 71. Часть функций сравнения возвращает false при несовпадении длины, а timingSafeEqual в Node вместо false бросает исключение.
Можно ли проверить подпись после того, как фреймворк разобрал JSON?
Надежно — нет. Повторная сериализация воспроизводит исходные байты только для payload без чисел с плавающей точкой, без целых больше 2^53, без escape-последовательностей Unicode и без лишних пробелов. Стоит появиться одному из них — digest меняется, поэтому проверка проходит в тестах и падает на части событий в проде.
Почему Stripe и GitHub дают разные подписи для одного payload?
Потому что они считают хеш от разных строк. GitHub подписывает одно сырое тело. Stripe подписывает timestamp, буквальную ., а затем тело — поэтому один payload, доставленный в два разных момента, дает два разных digest. Slack ставит впереди v0: и свой timestamp. Алгоритм тот же, вход разный.
Каким должен быть допуск по timestamp?
Пять минут используют Stripe и Slack, и скопировать это — разумный вариант по умолчанию. Более короткие окна начинают отклонять законные доставки, едва часы сервера уйдут в дрейф. Более длинные расширяют период, в котором захваченный запрос можно переиграть. Прежде чем ослаблять допуск, синхронизируйте часы по NTP.
Возвращает ли timingSafeEqual false при разной длине?
Нет. Node бросает RangeError [ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH]: Input buffers must have the same byte length. Непойманным это превращается в 500 вместо 401, и вы идете отлаживать обработчик, а не строку выше сравнения. Сначала сравните длины и сами верните false.
Провайдер сменил секрет — почему часть webhook всё равно падает?
Окна ротации перекрываются. Stripe держит старый секрет действительным до 24 часов и присылает по одной подписи v1 на каждый активный секрет, поэтому код, который читает только первую v1, падает примерно на половине событий. Shopify может начать использовать новый секрет только через час.
Заключение
Проверка — это сравнение байтов, поэтому webhook signature verification failed всегда сводится к расхождению в байтах, а не к чему-то криптографическому. Пока ищете причину, держите измерения раздельно:
- Какие байты были подписаны: снимайте сырое тело раньше, чем его коснется любой парсер. Никогда не считайте хеш от пересериализованного объекта: он совпадает достаточно часто, чтобы пройти ваши тесты, и недостаточно часто, чтобы работать.
- Какие байты ключа использованы: текстовое, hex- и base64-чтение одного секрета дают три разных ключа.
- В какой кодировке шло сравнение: hex занимает 64 символа, base64 — 44, и оба описывают одни и те же 32 байта.
- Всё остальное: префикс с timestamp, префикс значения, окно допуска, перекрытие ротации и транспортный уровень, примерно в этом порядке вероятности.
- Как именно вы сравнивали: проверьте длину, затем используйте функцию сравнения за фиксированное время из своей среды исполнения.
Когда нужно значение, которому можно доверять при сравнении, вычислите его вне приложения: вставьте тело и секрет в генератор HMAC, и он покажет, какая из сторон неправа.