Чтобы проверить подпись webhook, повторно вычислите HMAC ровно по байтам, которые подписал отправитель, используя общий с ним секрет, и сравните свое значение с заголовком подписи за постоянное время. Затем проверьте, что подписанная временная метка достаточно свежая. Для webhook Elido это означает HMAC-SHA256 с полным секретом whsec_... в качестве ключа, по {X-Webhook-Timestamp}.{raw body}, в шестнадцатеричной кодировке, с префиксом v1= и сравнением с X-Elido-Signature.
На этом алгоритм заканчивается. Сбои вызывают детали вокруг него: слишком рано запущенный парсер тела, декодированный секрет, сравнение шестнадцатеричного дайджеста со значением base64. В этой статье разобрана проверка подписи HMAC для webhook в целом, а затем рабочий код Elido для Node, Python, Go и узла Code в n8n, плюс окно защиты от replay-атак и ротация секрета, которые отсутствуют в большинстве быстрых руководств.
Если вы еще не настроили конечную точку, начните с webhook для событий ссылок, где перечислены все типы событий и оболочка полезной нагрузки. Эта страница продолжает с момента, когда подписанный запрос поступил на ваш сервер.
Как работают подписи HMAC для webhook
Конечная точка webhook - это публичный URL. Любой, кто его найдет, может отправить POST с телом JSON, похожим на настоящее событие, поэтому получателю нужно подтверждение происхождения. HMAC предоставляет его без лишних затрат: отправитель и получатель используют общий секрет, отправитель вычисляет HMAC-SHA256(secret, message) и помещает результат в заголовок, а получатель выполняет те же вычисления и сравнивает. Без секрета никто не создаст совпадающее значение, а изменение одного байта сообщения меняет весь дайджест.
У разных поставщиков различаются три детали, и ошибка в любой из них нарушает проверку:
- Что входит в сообщение. GitHub подписывает только необработанное тело. Stripe и Elido подписывают временную метку, точку и тело. Спецификация Standard Webhooks подписывает идентификатор сообщения, временную метку и тело.
- Как кодируется дайджест. Шестнадцатеричный формат или base64, с префиксом схемы, например
v1=илиsha256=. - Что является ключом. Некоторые поставщики декодируют секрет из base64 после его префикса. Elido этого не делает: ключ - полная строка
whsec_в виде байтов UTF-8.
Включение временной метки в подписываемое сообщение важно. Оно не дает злоумышленнику совместить старое, но корректно подписанное тело со свежим заголовком временной метки, а именно это и делает окно защиты от replay-атак применимым.
Что подписывает Elido и в каких заголовках это передается
Каждая доставка на конечную точку event или siem представляет собой POST с Content-Type: application/json и телом вида {"type", "workspace_id", "data", "timestamp"}. Типы конечных точек для чатов (Discord, Telegram, Sentry) проходят аутентификацию через свой URL и не содержат заголовков HMAC, поэтому все ниже относится только к первым двум типам.
| Заголовок | Значение | Как использовать |
|---|---|---|
X-Elido-Signature | v1= + 64 символа нижнего регистра в шестнадцатеричном формате | Сравнить с вычисленным значением |
X-Webhook-Signature | То же значение, что выше | Старый псевдоним: читайте любой, но не оба |
X-Webhook-Timestamp | Секунды Unix, например 1789000000 | Часть подписанного сообщения: проверьте давность |
X-Elido-Signature-Previous | v1= + шестнадцатеричное значение, подписанное старым секретом | Есть только в льготное окно ротации |
X-Webhook-Event | Имя события, например link.created | Направляйте событие после проверки |
X-Webhook-Delivery | Числовой идентификатор доставки, одинаковый при повторах | Ключ устранения дубликатов для идемпотентной обработки |
Секрет создается при создании конечной точки: whsec_, за которым следуют 64 шестнадцатеричных символа. Он возвращается один раз в ответе на создание и больше никогда, поэтому сразу поместите его в хранилище секретов.
Вот тестовый вектор, с которым можно проверить код. При секрете whsec_test_only_do_not_use, временной метке 1789000000 и теле {"type":"link.created","workspace_id":42} правильное значение заголовка:
v1=b9369aa411a8b7ce705bcd5bba112dea9d72d2e787aa88959ff62f33942d1a15
Его можно воспроизвести из оболочки. Это первое, что я делаю, когда получатель расходится с отправителем:
printf '%s.%s' 1789000000 '{"type":"link.created","workspace_id":42}' \
| openssl dgst -sha256 -hmac 'whsec_test_only_do_not_use' -r
Здесь есть ловушка: поле timestamp в самом теле - это время, когда произошло событие. Подписанная временная метка находится в заголовке X-Webhook-Timestamp и задается при отправке запроса. Не путайте их.
Проверка подписи webhook в Node
В Express исправление большинства сбоев укладывается в одну строку: подключите express.raw() к маршруту webhook, чтобы req.body был Buffer с байтами ровно в том виде, в каком они пришли. Зарегистрируйте этот маршрут до любого глобального app.use(express.json()), поскольку после того, как парсер JSON прочитает поток, необработанному парсеру уже нечего читать.
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = process.env.ELIDO_WEBHOOK_SECRET; // the full whsec_... string
const TOLERANCE_SEC = 300;
function matches(expected, got) {
const a = Buffer.from(expected);
const b = Buffer.from(got ?? "");
return a.length === b.length && timingSafeEqual(a, b);
}
const app = express();
app.post(
"/webhooks/elido",
express.raw({ type: "application/json" }),
(req, res) => {
const ts = req.get("X-Webhook-Timestamp") ?? "";
if (
!/^\d+$/.test(ts) ||
Math.abs(Date.now() / 1000 - Number(ts)) > TOLERANCE_SEC
) {
return res.status(400).send("stale or missing timestamp");
}
const expected =
"v1=" +
createHmac("sha256", SECRET)
.update(`${ts}.`)
.update(req.body)
.digest("hex");
const ok = [
req.get("X-Elido-Signature"),
req.get("X-Elido-Signature-Previous"),
].some((got) => matches(expected, got));
if (!ok) return res.status(401).send("bad signature");
const event = JSON.parse(req.body.toString("utf8"));
// enqueue event, then acknowledge fast
res.sendStatus(200);
},
);
Проверка длины здесь не для красоты. timingSafeEqual выбрасывает исключение при буферах разной длины вместо возврата false. Если вы используете SDK TypeScript из быстрого старта API и SDK, webhooks.verify() в @elido/sdk выполняет тот же HMAC и сравнение за постоянное время. Но явно передайте { maxSkewSec: 300 }: без этой опции он вообще не проверяет давность временной метки.
Проверка HMAC SHA256 для webhook в Python и Go
Стандартная библиотека Python справляется сама. В FastAPI await request.body() возвращает необработанные байты, а во Flask вызывайте request.get_data() до любого обращения к request.json.
import hashlib
import hmac
import json
import os
import time
from fastapi import FastAPI, HTTPException, Request
SECRET = os.environ["ELIDO_WEBHOOK_SECRET"].strip().encode()
TOLERANCE = 300
app = FastAPI()
def verify(raw: bytes, ts: str, candidates: list) -> bool:
if not ts.isdigit() or abs(time.time() - int(ts)) > TOLERANCE:
return False
digest = hmac.new(SECRET, ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
expected = "v1=" + digest
return any(c and hmac.compare_digest(c, expected) for c in candidates)
@app.post("/webhooks/elido")
async def elido_webhook(request: Request):
raw = await request.body()
h = request.headers
sigs = [h.get("x-elido-signature"), h.get("x-elido-signature-previous")]
if not verify(raw, h.get("x-webhook-timestamp", ""), sigs):
raise HTTPException(status_code=401, detail="bad signature")
event = json.loads(raw)
# enqueue event
return {"ok": True}
В Go прочитайте тело один раз, ограничьте его размер и используйте hmac.Equal из crypto/hmac, который сравнивает за постоянное время. Я собираю сообщение из строки заголовка ровно в полученном виде, а не форматирую повторно разобранное целое число.
package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"strconv"
"time"
)
const toleranceSec = 300
// Verify returns the raw body when the request carries a valid Elido signature.
func Verify(w http.ResponseWriter, r *http.Request, secret []byte) ([]byte, bool) {
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
if err != nil {
return nil, false
}
tsHeader := r.Header.Get("X-Webhook-Timestamp")
ts, err := strconv.ParseInt(tsHeader, 10, 64)
if err != nil {
return nil, false
}
if age := time.Now().Unix() - ts; age > toleranceSec || age < -toleranceSec {
return nil, false
}
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(tsHeader + "."))
mac.Write(body)
expected := []byte("v1=" + hex.EncodeToString(mac.Sum(nil)))
for _, name := range []string{"X-Elido-Signature", "X-Elido-Signature-Previous"} {
if got := r.Header.Get(name); got != "" && hmac.Equal([]byte(got), expected) {
return body, true
}
}
return nil, false
}
Все три версии отвечают 401 при неверной подписи и разбирают JSON только после успешной проверки. Elido считает любой ответ с кодом вне 2xx неудачной попыткой, поэтому, если проверяющий код неверен, настоящие доставки накапливаются как ответы 401 в журнале доставок конечной точки. Это первое место, куда стоит заглянуть после развертывания.
Хотите увидеть это на живом трафике? Создайте конечную точку в рабочем пространстве на странице функций webhook и направьте ее в локальный туннель. Журнал доставок покажет код состояния, который ваш проверяющий код вернул для каждой попытки.
Проверка подписи webhook внутри узла Code в n8n
n8n может выполнить такую же проверку без дополнительного сервиса. Включите параметр Raw Body в узле Webhook, который сохранит нетронутый запрос как двоичные данные, затем сразу после него добавьте узел Code. Встроенный модуль crypto разрешен в узле Code n8n, поэтому этот код запускается без изменений:
const crypto = require("crypto");
const h = $input.first().json.headers;
const raw = await this.helpers.getBinaryDataBuffer(0, "data");
const ts = h["x-webhook-timestamp"] ?? "";
if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
throw new Error("stale delivery");
}
const expected = Buffer.from(
"v1=" +
crypto
.createHmac("sha256", $env.ELIDO_WEBHOOK_SECRET)
.update(`${ts}.`)
.update(raw)
.digest("hex"),
);
const ok = ["x-elido-signature", "x-elido-signature-previous"].some((name) => {
const got = Buffer.from(h[name] ?? "");
return (
got.length === expected.length && crypto.timingSafeEqual(got, expected)
);
});
if (!ok) throw new Error("bad signature");
return [{ json: JSON.parse(raw.toString("utf8")) }];
Выброшенная ошибка останавливает выполнение, поэтому далее ничего не запускается для подделанного события. Учтите одну особенность самостоятельного размещения: если в вашем экземпляре задано N8N_BLOCK_ENV_ACCESS_IN_NODE=true, узел Code не сможет прочитать $env, секрет окажется пустым, и проверка будет неудачной для каждой доставки. В руководстве по самостоятельному размещению n8n разобрана настройка обратного прокси, а в статье о сокращателе URL для n8n показано, что можно построить после поступления событий.
Окно защиты от replay-атак и ротация секрета
Действительная подпись доказывает, кто отправил запрос, но не когда. Тот, кто перехватит одну подписанную доставку из строки журнала или неправильно настроенного прокси, может отправить ее снова через неделю, и HMAC все равно совпадет. Проверка временной метки закрывает этот пробел, и это ваша задача: обработчик доставок Elido подписывает временную метку, но не применяет окно на вашей стороне. Я использую 300 секунд. Синхронизируйте часы получателя по NTP, ведь сервер, часы которого уйдут на несколько минут, начнет отклонять честный трафик.
Повторные попытки не упираются в это окно. Каждая попытка получает свежий X-Webhook-Timestamp и новую подпись, а X-Webhook-Delivery остается тем же. Такое разделение дает обе защиты: временная метка ограничивает срок пригодности перехваченного запроса, а уникальный индекс по идентификатору доставки не дает обработать законную повторную попытку дважды. В статье о лимитах запросов и идемпотентности тот же шаблон разобран для входящей стороны API.
Ротация выполняется через POST /v1/workspaces/{workspace_id}/webhooks/{id}/rotate-secret или кнопкой Rotate на странице конечной точки. Ответ один раз содержит новый секрет, а также grace_window_days (7) и previous_expires_at. В течение этих семи дней каждая доставка содержит две подписи:
X-Elido-Signature, созданную новым секретомX-Elido-Signature-Previous, созданную старым секретом
Именно поэтому каждый фрагмент кода выше проверяет оба заголовка по единственному сохраненному секрету. Получатель, в котором еще работает старый секрет, сопоставит второй заголовок; после развертывания нового он сопоставит первый. В промежутке ничего не сломается. Но относитесь к заголовку предыдущего ключа как к переходному механизму, а не гарантии, и разверните новый секрет в начале недели.
Почему проверка подписи webhook не проходит
Когда я помогаю кому-то отладить это, почти всегда повторяется один и тот же короткий список. Пройдите его по порядку:
- Повторно сериализованный JSON.
JSON.stringify(req.body)илиjson.dumps(payload)создают байты, отличные от тех, что хешировал отправитель: порядок ключей, пробелы, экранированные косые черты, экранирование Unicode. Хешируйте необработанное тело. Если фреймворк уже разобрал его, исправьте порядок промежуточных обработчиков вместо попытки восстановить строку. - Неверные байты ключа. Elido использует всю строку
whsec_...как ключ HMAC. Удаление префикса, шестнадцатеричное декодирование остатка или декодирование из base64 (так поступают библиотеки Standard Webhooks) дадут другой ключ. То же происходит из-за завершающего перевода строки отechoв файле секретов, поэтому пример Python вызывает.strip(). - Несовпадение кодировки. Сравнивайте
v1=и шестнадцатеричное значение в нижнем регистре с заголовком. Дайджест base64, шестнадцатеричная строка в верхнем регистре или отсутствующий префикс никогда не совпадут. - Неверная временная метка. Используйте строку заголовка
X-Webhook-Timestamp, а не полеtimestampтела и не число, которое вы разобрали и заново отформатировали.
Есть еще две менее очевидные причины: сравнение через == работает, но раскрывает данные через время выполнения, поэтому используйте функцию постоянного времени из вашего языка; а прокси, который распаковывает или перекодирует тела, тоже нарушит проверку, хотя для обычных POST с JSON это редко.
Если стороны все еще расходятся, запишите в журнал временную метку, длину тела и первые несколько шестнадцатеричных символов своего дайджеста, затем выполните приведенную ранее строку openssl на тех же входных данных. Та сторона, которая совпадет с openssl, права. О том, как подписание сочетается с другими мерами защиты, которые стоит проверить у любого поставщика, читайте в контрольном списке безопасности сокращателя URL.
Читайте основную статью: webhook для событий ссылок.
Другие материалы блога
- Webhook для событий ссылок - типы событий, оболочка полезной нагрузки и политика повторных попыток.
- Webhook и опрос для отслеживания кликов - когда push выгоднее pull и когда нет.
- Автоматизация ссылок на собственном сервере с n8n - обратный прокси, режим очереди и проверки подписи в одном стеке.
- API сокращателя URL: лимиты запросов, повторы, идемпотентность - шаблоны устранения дубликатов с другой стороны.
- Контрольный список безопасности сокращателя URL - девять мер контроля для проверки у любого поставщика.
Частые вопросы
Как проверить подпись webhook?
Повторно вычислите HMAC ровно по тем данным, которые подписал отправитель, используя общий секрет, и сравните результат с заголовком подписи за постоянное время. Для Elido это HMAC-SHA256 по значению X-Webhook-Timestamp, точке и необработанному телу, закодированный в шестнадцатеричном формате с префиксом v1=. Отклоняйте запрос, если ничего не совпало или временная метка устарела.
Почему проверка подписи webhook постоянно не проходит?
Почти всегда потому, что вы хешировали не те байты, что и отправитель. Сначала сработал парсер тела JSON и вы повторно сериализовали объект, либо декодировали секрет, либо сравнили шестнадцатеричное значение с base64. Хешируйте необработанные байты запроса, используйте строку секрета ровно в выданном виде и записывайте оба значения рядом в журнал.
Что такое HMAC в webhook?
HMAC - это хеш с ключом: отправитель добавляет общий с вами секрет в хеш SHA-256 сообщения. Только тот, у кого есть секрет, может создать совпадающее значение, поэтому действительная подпись доказывает, что запрос пришел от отправителя и его тело не изменили в пути.
Как предотвратить replay-атаки на webhook?
Подписывайте временную метку вместе с телом и отклоняйте любой запрос, чья временная метка старше нескольких минут. Обычное окно составляет пять минут. Затем сохраняйте идентификатор доставки и пропускайте уже обработанные идентификаторы. Elido подписывает временную метку, но проверку свежести оставляет вашему получателю.
Использовать шестнадцатеричный формат или base64 для подписи HMAC-SHA256 webhook?
Тот формат, который документирует отправитель, поскольку две кодировки одного и того же дайджеста никогда не совпадут при сравнении. Elido отправляет шестнадцатеричное значение в нижнем регистре после префикса v1=. Shopify и спецификация Standard Webhooks используют base64, а GitHub - шестнадцатеричный формат после sha256=. Перед сравнением кодируйте дайджест таким же способом.
Как сменить секрет webhook, не теряя события?
Используйте отправителя, который некоторое время подписывает данные обоими ключами. После ротации секрета конечной точки Elido каждая доставка в течение семи дней содержит X-Elido-Signature с новым ключом и X-Elido-Signature-Previous со старым. Принимайте любой из заголовков, разверните новый секрет, а старый истечет сам.
Попробуйте Elido
Вставьте URL - получите короткую ссылку
Без регистрации. Ссылка живёт 30 дней. Зарегистрируйтесь, чтобы оставить её навсегда.
Бесплатно, без регистрации · 2 в день