10 хв читанняІнженерія

Перевірка підписів webhook: HMAC-SHA256 у Node, Python, Go

Як перевірити підпис webhook за допомогою HMAC-SHA256: сире тіло, порівняння зі сталим часом, вікно повторного відтворення та ротація секретів, з кодом для Node, Python, Go і n8n.

Marius Voß
DevRel · edge infra
Піксельна обкладинка, що показує, як перевіряти заголовки підпису webhook: timestamp і сире тіло хешуються через HMAC-SHA256 у hex-значення v1=, яке порівнюють за сталий час поруч із піксельним ключем

Щоб перевірити підпис 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) і кладе результат у заголовок, а отримувач виконує те саме обчислення та порівнює. Без секрету ніхто не може створити відповідне значення, а зміна одного байта повідомлення змінює весь хеш.

Три деталі відрізняються між провайдерами, і кожна з них ламає перевірку, якщо помилитися:

  1. Що входить у повідомлення. GitHub підписує лише сире тіло. Stripe і Elido підписують timestamp, крапку та тіло. Специфікація Standard Webhooks підписує ID повідомлення, timestamp і тіло.
  2. Як закодований хеш. Hex або base64, із префіксом схеми на кшталт v1= або sha256=.
  3. Що є ключем. Деякі провайдери base64-декодують секрет після його префікса. Elido цього не робить: ключем є повний рядок whsec_ як UTF-8 байти.

Додавати timestamp у підписане повідомлення важливо. Це не дає зловмиснику поєднати старе, коректно підписане тіло зі свіжим заголовком timestamp, і саме завдяки цьому вікно повторного відтворення взагалі можна застосувати.

Як перевірити підпис webhook: Elido об'єднує unix timestamp, крапку та сире тіло, хешує це за допомогою HMAC-SHA256 із секретом whsec_ і надсилає v1= hex у X-Elido-Signature; отримувач повторно обчислює те саме значення із сирих байтів і заголовка timestamp, а потім порівнює за сталий час

Що підписує 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, 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: до ротації надсилається лише X-Elido-Signature; після rotate-secret протягом семиденного пільгового вікна доставки містять X-Elido-Signature з новим ключем і X-Elido-Signature-Previous зі старим ключем, тому отримувач із будь-яким секретом проходить перевірку; після завершення вікна підписує лише новий ключ

Чому перевірка підпису webhook не проходить

Коли я допомагаю комусь це налагодити, майже щоразу список той самий і короткий. Пройдіться ним по порядку:

  1. Повторно серіалізований JSON. JSON.stringify(req.body) або json.dumps(payload) створює інші байти, ніж ті, що хешував відправник: порядок ключів, пробіли, екрановані слеші, Unicode escape-послідовності. Хешуйте сире тіло. Якщо ваш фреймворк уже його розібрав, виправте порядок проміжних обробників замість спроб відбудувати рядок.
  2. Неправильні байти ключа. Elido використовує весь рядок whsec_... як ключ HMAC. Видалення префікса, hex-декодування решти або base64-декодування (що роблять бібліотеки Standard Webhooks) дає інший ключ. Завершальний символ нового рядка від echo у файлі секретів робить те саме, тому приклад Python викликає .strip().
  3. Невідповідність кодування. Порівнюйте v1= плюс hex у нижньому регістрі із заголовком. Base64-хеш, hex-рядок у верхньому регістрі або відсутній префікс ніколи не збігаються.
  4. Неправильний timestamp. Використовуйте рядок заголовка X-Webhook-Timestamp, а не поле timestamp у тілі й не число, яке ви розібрали та заново відформатували.

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

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

Читайте наріжну статтю: webhook для подій посилань.

Пов'язане в блозі

Поширені запитання

Як перевірити підпис 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 на день

Спробуйте Elido

URL-скорочувач із хостингом у ЄС: власні домени, глибока аналітика, відкритий API. Безкоштовний тариф - без кредитної картки.

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

Читати далі