Щоб перевірити підпис webhook, повторно обчисліть HMAC саме над тими байтами, які підписав відправник, використовуючи секрет, яким ви з ним ділитеся, і порівняйте своє значення із заголовком підпису за сталий час. Потім перевірте, що підписаний timestamp свіжий. Для webhook Elido це означає HMAC-SHA256 із ключем у вигляді всього вашого секрету whsec_..., над {X-Webhook-Timestamp}.{raw body}, у кодуванні hex, із префіксом v1=, зіставлений із X-Elido-Signature.
Це весь алгоритм. Помилки виникають у деталях навколо нього: парсер тіла, який спрацював зарано, секрет, який декодували, hex-хеш, який порівнюють із base64. У цій статті розбираємо перевірку підписів webhook HMAC загалом, а потім робочий код Elido для Node, Python, Go і вузла Code в n8n, плюс вікно повторного відтворення та ротацію секретів, які більшість коротких посібників пропускає.
Якщо ви ще не налаштували кінцеву точку, почніть із webhook для подій посилань, де перелічено всі типи подій і обгортку корисного навантаження. Ця сторінка продовжує з моменту, коли підписаний запит уже потрапляє на ваш сервер.
Як працюють підписи webhook HMAC
Кінцева точка webhook - це публічний URL. Будь-хто, хто його знайде, може надіслати POST із тілом JSON, схожим на справжню подію, тому отримувачу потрібен доказ походження. HMAC дає його недорого: відправник і отримувач мають спільний секрет, відправник обчислює HMAC-SHA256(secret, message) і кладе результат у заголовок, а отримувач виконує те саме обчислення та порівнює. Без секрету ніхто не може створити відповідне значення, а зміна одного байта повідомлення змінює весь хеш.
Три деталі відрізняються між провайдерами, і кожна з них ламає перевірку, якщо помилитися:
- Що входить у повідомлення. GitHub підписує лише сире тіло. Stripe і Elido підписують timestamp, крапку та тіло. Специфікація Standard Webhooks підписує ID повідомлення, timestamp і тіло.
- Як закодований хеш. Hex або base64, із префіксом схеми на кшталт
v1=абоsha256=. - Що є ключем. Деякі провайдери base64-декодують секрет після його префікса. Elido цього не робить: ключем є повний рядок
whsec_як UTF-8 байти.
Додавати timestamp у підписане повідомлення важливо. Це не дає зловмиснику поєднати старе, коректно підписане тіло зі свіжим заголовком timestamp, і саме завдяки цьому вікно повторного відтворення взагалі можна застосувати.
Що підписує Elido і які заголовки це передають
Кожна доставка до кінцевої точки типу event або siem - це POST із Content-Type: application/json і тілом структури {"type", "workspace_id", "data", "timestamp"}. Кінцеві точки чатового типу (Discord, Telegram, Sentry), автентифікуються через свій URL і не мають заголовків HMAC, тому все нижче стосується лише перших двох типів.
| Заголовок | Значення | Що з ним робити |
|---|---|---|
X-Elido-Signature | v1= + 64 символи hex у нижньому регістрі | Порівняти з вашим обчисленим значенням |
X-Webhook-Signature | Те саме значення, що вище | Старий псевдонім; читайте будь-який, не обидва |
X-Webhook-Timestamp | Unix секунди, наприклад 1789000000 | Частина підписаного повідомлення; перевірте вік |
X-Elido-Signature-Previous | v1= + hex, підписаний старим секретом | Є лише під час пільгового вікна ротації |
X-Webhook-Event | Назва події, наприклад link.created | Спрямуйте подію (після перевірки) |
X-Webhook-Delivery | Числовий ID доставки, сталий між повторами | Ключ усунення дублікатів для ідемпотентної обробки |
Секрет генерується для вас під час створення кінцевої точки: whsec_, після якого йдуть 64 hex-символи. Він повертається один раз у відповіді створення і більше ніколи, тож одразу покладіть його у своє сховище секретів.
Ось тестовий вектор, на якому можна перевірити свій код. Із секретом whsec_test_only_do_not_use, timestamp 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 у тілі - це час, коли сталася подія. Підписаний 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. Якщо ви користуєтеся TypeScript SDK із короткого старту API та SDK, webhooks.verify() в @elido/sdk виконує той самий HMAC і порівняння зі сталим часом. Але передайте { maxSkewSec: 300 } явно: без цієї опції він узагалі не перевіряє вік timestamp.
Перевірка webhook HMAC SHA256 у 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 у журналі доставок кінцевої точки. Це перше місце, куди варто дивитися після розгортання.
Хочете побачити це на живому трафіку? Створіть кінцеву точку в робочій області зі сторінки можливості webhooks і спрямуйте її на локальний тунель; журнал доставок показує код стану, який ваш перевірник повернув для кожної спроби.
Перевірка підпису 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 показує, що будувати після надходження подій.
Вікна повторного відтворення та ротація секретів
Чинний підпис доводить, хто надіслав запит, але не коли. Хтось, хто захопить одну підписану доставку з рядка журналу або неправильно налаштованого проксі, може надіслати її знову через тиждень, і HMAC усе ще збігатиметься. Перевірка timestamp закриває цю прогалину, і це ваша відповідальність: процес доставки Elido підписує timestamp, але не застосовує жодного вікна на вашому боці. Я використовую 300 секунд. Тримайте годинник отримувача синхронізованим через NTP, бо сервер, годинник якого розійдеться на кілька хвилин, почне відкидати легітимний трафік.
Повтори не ламають вікно. Кожна спроба отримує свіжий X-Webhook-Timestamp і новий підпис, тоді як X-Webhook-Delivery залишається тим самим. Такий поділ дає вам обидва захисти: timestamp обмежує, як довго захоплений запит лишається придатним, а унікальний індекс на ID доставки не дає легітимному повтору обробитися двічі. Стаття про обмеження частоти та ідемпотентність описує той самий підхід на боці вхідного 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 escape-послідовності. Хешуйте сире тіло. Якщо ваш фреймворк уже його розібрав, виправте порядок проміжних обробників замість спроб відбудувати рядок. - Неправильні байти ключа. Elido використовує весь рядок
whsec_...як ключ HMAC. Видалення префікса, hex-декодування решти або base64-декодування (що роблять бібліотеки Standard Webhooks) дає інший ключ. Завершальний символ нового рядка відechoу файлі секретів робить те саме, тому приклад Python викликає.strip(). - Невідповідність кодування. Порівнюйте
v1=плюс hex у нижньому регістрі із заголовком. Base64-хеш, hex-рядок у верхньому регістрі або відсутній префікс ніколи не збігаються. - Неправильний timestamp. Використовуйте рядок заголовка
X-Webhook-Timestamp, а не полеtimestampу тілі й не число, яке ви розібрали та заново відформатували.
Є ще дві менші причини: порівняння через == працює, але розкриває часові відмінності, тож використовуйте функцію сталого часу, яку дає ваша мова; а проксі, який розпаковує або перекодовує тіла, теж зламає перевірку, хоча для звичайних JSON POST це рідко.
Коли обидві сторони все одно не сходяться, залогуйте timestamp, довжину тіла та перші кілька hex-символів вашого хешу, а потім запустіть наведену вище команду openssl на тих самих входах. Та сторона, яка збігається з openssl, правильна. Про те, де підписування вписується серед інших засобів контролю, які варто перевіряти в будь-якого провайдера, читайте в чеклисті безпеки скорочувача URL.
Читайте наріжну статтю: webhook для подій посилань.
Пов'язане в блозі
- Webhook для подій посилань - типи подій, обгортка корисного навантаження і політика повторів.
- Webhook проти опитування для відстеження кліків - коли надсилання краще за опитування, а коли ні.
- Автоматизація посилань на власному сервері з n8n - зворотний проксі, режим черги і перевірки підписів в одному стеку.
- API скорочувача URL: обмеження частоти, повтори, ідемпотентність - підхід до усунення дублікатів з іншого напрямку.
- Чеклист безпеки скорочувача URL - дев'ять засобів контролю, які варто перевірити в будь-якого провайдера.
Поширені запитання
Як перевірити підпис webhook?
Повторно обчисліть HMAC саме для того, що підписав відправник, використовуючи спільний секрет, і порівняйте результат із заголовком підпису за сталий час. Для Elido це означає HMAC-SHA256 над значенням X-Webhook-Timestamp, крапкою та сирим тілом, закодований у hex із префіксом v1=. Відхиліть запит, якщо збігу немає або timestamp застарів.
Чому перевірка підпису webhook постійно не проходить?
Майже завжди тому, що ви хешували інші байти, ніж відправник. Спершу спрацював парсер тіла JSON, і ви повторно серіалізували об'єкт, або ви декодували секрет, або порівняли hex із base64. Хешуйте сирі байти запиту, використовуйте рядок секрету саме в тому вигляді, у якому його видано, і логуйте обидва значення поруч.
Що таке HMAC у webhook?
HMAC - це хеш із ключем: відправник додає секрет, яким ділиться з вами, до SHA-256 хешу повідомлення. Лише той, хто має секрет, може створити відповідне значення, тому чинний підпис доводить, що запит надійшов від відправника і що тіло не було змінене дорогою.
Як запобігти атакам повторного відтворення webhook?
Підписуйте timestamp разом із тілом і відхиляйте будь-який запит, timestamp якого старший за кілька хвилин; п'ять хвилин - поширене вікно. Потім зберігайте ID доставки та пропускайте ID, які вже обробили. Elido підписує timestamp, але перевірку свіжості залишає вашому отримувачу.
Використовувати hex чи base64 для підпису webhook HMAC-SHA256?
Те, що документує відправник, бо два кодування одного хешу ніколи не будуть рівними під час порівняння. Elido надсилає hex у нижньому регістрі після префікса v1=. Shopify і специфікація Standard Webhooks використовують base64, а GitHub використовує hex після sha256=. Закодуйте свій хеш так само перед порівнянням.
Як ротувати секрет webhook без втрати подій?
Використовуйте відправника, який певний час підписує обома ключами. Після ротації секрету кінцевої точки в Elido кожна доставка містить X-Elido-Signature з новим ключем і X-Elido-Signature-Previous зі старим протягом семи днів. Приймайте будь-який із цих заголовків, розгорніть новий секрет, і старий спливе сам.
Спробуйте Elido
Вставте URL - отримайте коротке посилання
Без реєстрації. Посилання живе 30 днів. Зареєструйтесь, щоб зберегти назавжди.
Безкоштовно, без реєстрації · 2 на день