10 мин чтенияИнженерия

Проверка подписи вебхука: HMAC-SHA256 в Node, Python, Go

Как проверить подпись вебхука по HMAC-SHA256: сырое тело, сравнение за постоянное время, окно повторов и ротация секрета.

Marius Voß
DevRel · edge infra

Чтобы проверить подпись вебхука, пересчитайте HMAC от ровно тех байтов, которые подписал отправитель, общим с ним секретом и сравните своё значение с заголовком подписи за постоянное время. Затем убедитесь, что подписанная метка времени свежая. Для вебхуков Elido это HMAC-SHA256 с ключом в виде всего вашего секрета whsec_..., от {X-Webhook-Timestamp}.{сырое тело}, в hex с префиксом v1=, и сравнение с X-Elido-Signature.

Это весь алгоритм. Сбои возникают из-за деталей вокруг него: парсер тела отработал слишком рано, секрет оказался декодирован, hex-дайджест сравнили с base64. Этот материал рассказывает о проверке подписи вебхука по HMAC в целом, затем даёт рабочий код Elido для Node, Python, Go и узла Code в n8n, а также окно повторов и ротацию секрета, о которых большинство быстрых стартов умалчивает.

Если вы ещё не настроили конечную точку, начните с вебхуков для событий ссылок: там перечислены все типы событий и формат конверта данных. Эта страница начинается с того момента, когда подписанный запрос приходит на ваш сервер.

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

Конечная точка вебхука это публичный URL. Любой, кто её найдёт, может отправить POST с телом JSON, похожим на настоящее событие, поэтому получателю нужно подтверждение источника. HMAC даёт его дёшево: отправитель и получатель делят секрет, отправитель вычисляет HMAC-SHA256(secret, message) и кладёт результат в заголовок, а получатель делает то же вычисление и сравнивает. Без секрета никто не сможет получить совпадающее значение, а изменение одного байта сообщения меняет весь дайджест.

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

  1. Что входит в сообщение. GitHub подписывает только сырое тело. Stripe и Elido подписывают метку времени, точку и тело. Спецификация Standard Webhooks подписывает ID сообщения, метку времени и тело.
  2. Как закодирован дайджест. Hex или base64, с префиксом схемы вроде v1= или sha256=.
  3. Что является ключом. Некоторые провайдеры декодируют секрет из base64 после его префикса. Elido нет: ключ это вся строка whsec_ в виде байтов UTF-8.

Метка времени внутри подписанного сообщения важна. Она не даёт атакующему соединить старое, верно подписанное тело со свежим заголовком метки времени, а именно это делает окно повторов вообще применимым.

Как проверить подпись вебхука: Elido соединяет unix-метку времени, точку и сырое тело, хеширует HMAC-SHA256 под секретом whsec_ и отправляет v1= hex в X-Elido-Signature; получатель пересчитывает то же значение из сырых байтов и заголовка метки времени, затем сравнивает за постоянное время Форма добавления эндпоинта вебхука в Elido с URL эндпоинта, выбором событий и секретом подписи, который получатели используют для проверки HMAC

Что подписывает Elido и в каких заголовках

Каждая доставка на конечную точку event или siem это POST с Content-Type: application/json и телом вида {"type", "workspace_id", "data", "timestamp"}. Конечные точки чатового типа (Discord, Telegram, Sentry) аутентифицируются через свой URL и заголовков HMAC не несут, поэтому всё ниже относится только к первым двум видам.

ЗаголовокЗначениеЧто с ним делать
X-Elido-Signaturev1= + 64 hex-символа в нижнем регистреСравнить с вычисленным значением
X-Webhook-SignatureТо же значение, что вышеСтарый псевдоним; читайте любой, не оба
X-Webhook-TimestampUnix-секунды, например 1789000000Часть подписанного сообщения; проверьте возраст
X-Elido-Signature-Previousv1= + hex, подписано старым секретомЕсть только в окне отсрочки при ротации
X-Webhook-EventНазвание события, например link.createdМаршрутизируйте событие (после проверки)
X-Webhook-DeliveryЧисловой ID доставки, одинаковый при повторахКлюч дедупликации для идемпотентной обработки

Секрет вы получаете при создании: whsec_ и 64 hex-символа. Он возвращается один раз в ответе на создание и больше никогда, так что сразу кладите его в хранилище секретов.

Вот тестовый вектор, на котором можно прогнать ваш код: для секрета 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, она ставится при отправке запроса. Не путайте их.

Проверка подписи вебхука в Node

В Express исправление для большинства сбоев занимает одну строку: подключите express.raw() на маршруте вебхука, чтобы 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 } явно: без этой опции возраст метки времени вообще не проверяется.

Проверка вебхука 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 в журнале доставок конечной точки. Это первое место, куда смотреть после деплоя.

Хотите увидеть это на живом трафике? Создайте конечную точку в рабочем пространстве на странице функции вебхуков и направьте её на локальный туннель; журнал доставок показывает код статуса, который вернул ваш верификатор на каждую попытку.

Проверка подписи вебхука в узле 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 на своём хостинге, а что строить, когда события пришли, показывает материал об узле n8n для сокращателя URL.

Чем Elido отличается от Stripe, GitHub и Standard Webhooks

Если вы писали верификатор для другого провайдера, не копируйте его вслепую. Сверено с документацией каждого вендора 2026-10-25:

  • Stripe кладёт t= и одно или несколько значений v1= в один заголовок Stripe-Signature, подписывает timestamp.body HMAC-SHA256 в hex, а его библиотеки по умолчанию допускают пять минут (документация Stripe). Elido выносит метку времени в отдельный заголовок.
  • GitHub подписывает только сырое тело, отправляет sha256= плюс hex в X-Hub-Signature-256 и велит использовать сравнение за постоянное время (документация GitHub). Метки времени для проверки нет.
  • Standard Webhooks использует webhook-id, webhook-timestamp и webhook-signature, подписывает id.timestamp.body, кодирует в base64 с префиксом v1, и оставляет допуск на ваше усмотрение (спецификация). Роль ID у Elido играет X-Webhook-Delivery, но в подписанное сообщение он не входит.

Собственная схема Elido это signing.Sign в api-core: сообщение это Unix-метка времени, точка и тело, ключ это строка секрета как она хранится, а результат это v1= плюс hex в нижнем регистре. У Elido нет события link.clicked, так что не ждите доставок на уровне кликов по этому каналу.

Окна повторов и ротация секрета

Верная подпись доказывает, кто отправил запрос, но не когда. Тот, кто перехватил одну подписанную доставку, из строки лога или неправильно настроенного прокси, может отправить её снова через неделю, и HMAC всё равно совпадёт. Проверка метки времени закрывает этот пробел, и это ваша задача: обработчик доставки Elido подписывает метку времени, но не навязывает никакого окна на вашей стороне. Я использую 300 секунд. Держите часы получателя синхронизированными по NTP, потому что сервер, который уйдёт на несколько минут, начнёт отклонять честный трафик.

Повторные попытки окно не нарушают. Каждая попытка получает свежий X-Webhook-Timestamp и новую подпись, а X-Webhook-Delivery остаётся тем же. Это разделение даёт обе защиты: метка времени ограничивает, как долго перехваченный запрос остаётся пригодным, а уникальный индекс по 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, сделанная старым

Поэтому каждый фрагмент выше сверяет оба заголовка с одним имеющимся у него секретом. Получатель, у которого ещё старый секрет, совпадёт со вторым заголовком; после развёртывания нового совпадёт с первым. В промежутке ничего не падает. Но относитесь к заголовку предыдущего ключа как к мосту, а не как к гарантии, и выкладывайте новый секрет в начале недели.

Временная шкала ротации секрета вебхука: до ротации отправляется только X-Elido-Signature; после rotate-secret в течение семидневного окна отсрочки доставки несут X-Elido-Signature с новым ключом и X-Elido-Signature-Previous со старым.

Почему проверка подписи вебхука не проходит

Когда я помогаю кому-то отлаживать это, почти каждый раз список один и тот же короткий. Проходите его по порядку:

  1. Заново сериализованный JSON. JSON.stringify(req.body) или json.dumps(payload) даёт не те байты, что хешировал отправитель: порядок ключей, пробелы, экранированные слэши, юникод-экранирование. Хешируйте сырое тело. Если фреймворк уже разобрал его, исправьте порядок middleware, а не пытайтесь воссоздать строку.
  2. Не те байты ключа. Elido использует всю строку whsec_... как ключ HMAC. Отрезание префикса, hex-декодирование остатка или base64-декодирование (что делают библиотеки Standard Webhooks) даёт другой ключ. То же делает завершающий перевод строки от echo в файл секретов, поэтому в примере на Python вызывается .strip().
  3. Несовпадение кодировок. Сравнивайте v1= плюс hex в нижнем регистре с заголовком. Дайджест в base64, hex в верхнем регистре или отсутствующий префикс никогда не совпадут.
  4. Не та метка времени. Используйте строку заголовка X-Webhook-Timestamp, а не поле timestamp из тела и не число, которое вы разобрали и переформатировали.

Ещё две мелочи: сравнение через == работает, но выдаёт тайминг, поэтому используйте функцию сравнения за постоянное время, которая есть в вашем языке; и прокси, который распаковывает или перекодирует тела, тоже сломает проверку, хотя для простых POST с JSON это редкость.

Когда стороны всё ещё не согласны, залогируйте метку времени, длину тела и первые несколько hex-символов вашего дайджеста, затем прогоните строку openssl выше на тех же входных данных. Та сторона, что совпала с openssl, права. О том, где подпись стоит среди других мер контроля, которые стоит проверить у любого провайдера, см. чек-лист безопасности сервиса сокращения URL.

Читайте опорный материал: вебхуки для событий ссылок.

Связанные материалы в блоге

Частые вопросы

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

Пересчитайте HMAC от ровно того, что подписал отправитель, общим секретом и сравните результат с заголовком подписи за постоянное время. Для Elido это HMAC-SHA256 от значения X-Webhook-Timestamp, точки и сырого тела, в hex с префиксом v1=. Отклоните запрос, если ничего не совпало или метка времени устарела.

Почему проверка подписи моего вебхука постоянно не проходит?

Почти всегда потому, что вы хешировали не те байты, что отправитель. Сначала отработал парсер JSON, и вы заново сериализовали объект, или вы декодировали секрет, или сравнивали hex с base64. Хешируйте сырые байты запроса, берите секретную строку ровно в том виде, в каком она выдана, и логируйте оба значения рядом.

Что такое HMAC в вебхуке?

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

Как защититься от атак повторного воспроизведения вебхуков?

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

Использовать hex или base64 для подписи вебхука HMAC-SHA256?

То, что описывает отправитель, потому что две кодировки одного дайджеста никогда не равны при сравнении. Elido отправляет hex в нижнем регистре после префикса v1=. Shopify и спецификация Standard Webhooks используют base64, а GitHub использует hex после sha256=. Закодируйте свой дайджест так же, прежде чем сравнивать.

Как сменить секрет вебхука, не потеряв события?

Используйте отправителя, который какое-то время подписывает обоими ключами. После ротации секрета конечной точки Elido каждая доставка несёт X-Elido-Signature с новым ключом и X-Elido-Signature-Previous со старым в течение семи дней. Принимайте любой из заголовков, разверните новый секрет, и старый истечёт сам.

Попробуйте Elido

Вставьте URL - получите короткую ссылку

Без регистрации. Ссылка живёт 30 дней. Зарегистрируйтесь, чтобы оставить её навсегда.

Без регистрации. Зарегистрируйтесь бесплатно и получите 50 ссылок в месяц.

Попробуйте Elido

URL-сокращатель с хостингом в ЕС: собственные домены, глубокая аналитика, открытый API. Бесплатный тариф - без банковской карты.

Теги
verify webhook signature
webhook signature verification
hmac webhook
webhook hmac sha256
webhook replay attack
webhook secret rotation

Читать дальше